From b11b8b8c9b69bc0bb2c54f388d7bbae0737c297b Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Wed, 10 Feb 2021 20:20:55 -0700 Subject: [PATCH 01/24] [Security Solution][Detections] Adds list plugin Saved Objects to Security feature privilege (#90895) ## Summary Add's the list plugins Saved Objects (`exception-list` and `exception-list-agnostic`) to the `Security` feature privilege. Resolves https://github.com/elastic/kibana/issues/90715 ### Test Instructions Load pre-packaged roles/users, and ensure only those with the Kibana Space privilege `Security:All` have the ability to create/edit rules and exception lists (space-aware/agnostic). Users with `Security:Read` should only be able to view rules/exception lists. Pre-packaged security roles should no longer be granted the `Saved Objects Management` feature privilege, and this feature privilege should no longer be required to use any of the Detections features. To add test users: t1_analyst (`"siem": ["read"]`): ``` bash cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts/ ./roles_users/t1_analyst/post_detections_role.sh roles_users/t1_analyst/detections_role.json ./roles_users/t1_analyst/post_detections_user.sh roles_users/t1_analyst/detections_user.json ``` hunter (`"siem": ["all"]`): ``` bash cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts/ ./roles_users/t1_analyst/post_detections_role.sh roles_users/hunter/detections_role.json ./roles_users/t1_analyst/post_detections_user.sh roles_users/hunter/detections_user.json ``` Note: Be sure to remove these users after testing if using a public cluster. ### Checklist Delete any items that are not applicable to this PR. - [X] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials -- `docs` label added, will work with @jmikell821 on doc changes - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../detections_admin/detections_role.json | 3 +- .../scripts/roles_users/hunter/README.md | 1 - .../roles_users/hunter/detections_role.json | 3 +- .../platform_engineer/detections_role.json | 3 +- .../roles_users/reader/detections_role.json | 3 +- .../rule_author/detections_role.json | 3 +- .../soc_manager/detections_role.json | 3 +- .../t1_analyst/detections_role.json | 5 +- .../t2_analyst/detections_role.json | 5 +- .../security_solution/server/plugin.ts | 4 + .../roles_users_utils/index.ts | 1 - .../tests/create_exceptions.ts | 785 +++++++++--------- .../security_and_spaces/tests/create_rules.ts | 350 ++++---- 13 files changed, 616 insertions(+), 553 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/detections_admin/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/detections_admin/detections_role.json index 357b8cde8ad104..6c9b4e2cba49c6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/detections_admin/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/detections_admin/detections_role.json @@ -26,8 +26,7 @@ "siem": ["all"], "actions": ["read"], "builtInAlerts": ["all"], - "dev_tools": ["all"], - "savedObjectsManagement": ["all"] + "dev_tools": ["all"] }, "spaces": ["*"] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/README.md index f0060fb006e325..1344c5bbb0891c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/README.md +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/README.md @@ -2,7 +2,6 @@ This user can CRUD rules and signals. The main difference here is the user has ```json "builtInAlerts": ["all"], -"savedObjectsManagement": ["all"] ``` privileges whereas the T1 and T2 have "read" privileges which prevents them from creating rules diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json index f5482643fb2683..119fe5421c86c5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json @@ -30,8 +30,7 @@ "ml": ["read"], "siem": ["all"], "actions": ["read"], - "builtInAlerts": ["all"], - "savedObjectsManagement": ["all"] + "builtInAlerts": ["all"] }, "spaces": ["*"] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/platform_engineer/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/platform_engineer/detections_role.json index 75001292242c3e..17dbd90d179253 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/platform_engineer/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/platform_engineer/detections_role.json @@ -30,8 +30,7 @@ "ml": ["all"], "siem": ["all"], "actions": ["all"], - "builtInAlerts": ["all"], - "savedObjectsManagement": ["all"] + "builtInAlerts": ["all"] }, "spaces": ["*"] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/reader/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/reader/detections_role.json index de2aa18386188b..289aeca24d45eb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/reader/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/reader/detections_role.json @@ -24,8 +24,7 @@ "ml": ["read"], "siem": ["read"], "actions": ["read"], - "builtInAlerts": ["read"], - "savedObjectsManagement": ["read"] + "builtInAlerts": ["read"] }, "spaces": ["*"] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/rule_author/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/rule_author/detections_role.json index da69643f3c2d3e..0db8359c577640 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/rule_author/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/rule_author/detections_role.json @@ -28,8 +28,7 @@ "ml": ["read"], "siem": ["all"], "actions": ["read"], - "builtInAlerts": ["all"], - "savedObjectsManagement": ["all"] + "builtInAlerts": ["all"] }, "spaces": ["*"] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/soc_manager/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/soc_manager/detections_role.json index a6cb64ef83ba73..6962701ae5be35 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/soc_manager/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/soc_manager/detections_role.json @@ -28,8 +28,7 @@ "ml": ["read"], "siem": ["all"], "actions": ["all"], - "builtInAlerts": ["all"], - "savedObjectsManagement": ["all"] + "builtInAlerts": ["all"] }, "spaces": ["*"] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json index 10b0ffc9d98907..07827069dbc739 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json @@ -21,10 +21,9 @@ { "feature": { "ml": ["read"], - "siem": ["all"], + "siem": ["read"], "actions": ["read"], - "builtInAlerts": ["read"], - "savedObjectsManagement": ["read"] + "builtInAlerts": ["read"] }, "spaces": ["*"] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t2_analyst/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t2_analyst/detections_role.json index 58a069e03985ce..f554c916c6684d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t2_analyst/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t2_analyst/detections_role.json @@ -23,10 +23,9 @@ { "feature": { "ml": ["read"], - "siem": ["all"], + "siem": ["read"], "actions": ["read"], - "builtInAlerts": ["read"], - "savedObjectsManagement": ["read"] + "builtInAlerts": ["read"] }, "spaces": ["*"] } diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 6e03d81a7d3561..164ccfd7389194 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -219,6 +219,8 @@ export class Plugin implements IPlugin; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts index 1ae6aa80b219f4..e8beef3e58a431 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts @@ -14,7 +14,10 @@ import { deleteAllExceptions } from '../../../lists_api_integration/utils'; import { RulesSchema } from '../../../../plugins/security_solution/common/detection_engine/schemas/response'; import { getCreateExceptionListMinimalSchemaMock } from '../../../../plugins/lists/common/schemas/request/create_exception_list_schema.mock'; import { CreateExceptionListItemSchema } from '../../../../plugins/lists/common'; -import { EXCEPTION_LIST_URL } from '../../../../plugins/lists/common/constants'; +import { + EXCEPTION_LIST_ITEM_URL, + EXCEPTION_LIST_URL, +} from '../../../../plugins/lists/common/constants'; import { DETECTION_ENGINE_RULES_URL } from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -37,10 +40,13 @@ import { findImmutableRuleById, getPrePackagedRulesStatus, } from '../../utils'; +import { ROLES } from '../../../../plugins/security_solution/common/test'; +import { createUserAndRole, deleteUserAndRole } from '../roles_users_utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('es'); @@ -58,129 +64,19 @@ export default ({ getService }: FtrProviderContext) => { await esArchiver.unload('auditbeat/hosts'); }); - it('should create a single rule with a rule_id and add an exception list to the rule', async () => { - const { - body: { id, list_id, namespace_type, type }, - } = await supertest - .post(EXCEPTION_LIST_URL) - .set('kbn-xsrf', 'true') - .send(getCreateExceptionListMinimalSchemaMock()) - .expect(200); - - const ruleWithException: CreateRulesSchema = { - ...getSimpleRule(), - exceptions_list: [ - { - id, - list_id, - namespace_type, - type, - }, - ], - }; - - const rule = await createRule(supertest, ruleWithException); - const expected: Partial = { - ...getSimpleRuleOutput(), - exceptions_list: [ - { - id, - list_id, - namespace_type, - type, - }, - ], - }; - const bodyToCompare = removeServerGeneratedProperties(rule); - expect(bodyToCompare).to.eql(expected); - }); - - it('should create a single rule with an exception list and validate it ran successfully', async () => { - const { - body: { id, list_id, namespace_type, type }, - } = await supertest - .post(EXCEPTION_LIST_URL) - .set('kbn-xsrf', 'true') - .send(getCreateExceptionListMinimalSchemaMock()) - .expect(200); - - const ruleWithException: CreateRulesSchema = { - ...getSimpleRule(), - enabled: true, - exceptions_list: [ - { - id, - list_id, - namespace_type, - type, - }, - ], - }; - - const rule = await createRule(supertest, ruleWithException); - await waitForRuleSuccessOrStatus(supertest, rule.id); - const bodyToCompare = removeServerGeneratedProperties(rule); + describe('elastic admin', () => { + it('should create a single rule with a rule_id and add an exception list to the rule', async () => { + const { + body: { id, list_id, namespace_type, type }, + } = await supertest + .post(EXCEPTION_LIST_URL) + .set('kbn-xsrf', 'true') + .send(getCreateExceptionListMinimalSchemaMock()) + .expect(200); - const expected: Partial = { - ...getSimpleRuleOutput(), - enabled: true, - exceptions_list: [ - { - id, - list_id, - namespace_type, - type, - }, - ], - }; - expect(bodyToCompare).to.eql(expected); - }); - - it('should allow removing an exception list from an immutable rule through patch', async () => { - await installPrePackagedRules(supertest); - - // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: - // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json - // This rule has an existing exceptions_list that we are going to use - const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one exceptions_list - - // remove the exceptions list as a user is allowed to remove it from an immutable rule - await supertest - .patch(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send({ rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', exceptions_list: [] }) - .expect(200); - - const immutableRuleSecondTime = await getRule( - supertest, - '9a1a2dae-0b5f-4c3d-8305-a268d404c306' - ); - expect(immutableRuleSecondTime.exceptions_list.length).to.eql(0); - }); - - it('should allow adding a second exception list to an immutable rule through patch', async () => { - await installPrePackagedRules(supertest); - - const { id, list_id, namespace_type, type } = await createExceptionList( - supertest, - getCreateExceptionListMinimalSchemaMock() - ); - - // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: - // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json - // This rule has an existing exceptions_list that we are going to use - const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one - - // add a second exceptions list as a user is allowed to add a second list to an immutable rule - await supertest - .patch(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send({ - rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + const ruleWithException: CreateRulesSchema = { + ...getSimpleRule(), exceptions_list: [ - ...immutableRule.exceptions_list, { id, list_id, @@ -188,64 +84,11 @@ export default ({ getService }: FtrProviderContext) => { type, }, ], - }) - .expect(200); - - const immutableRuleSecondTime = await getRule( - supertest, - '9a1a2dae-0b5f-4c3d-8305-a268d404c306' - ); - - expect(immutableRuleSecondTime.exceptions_list.length).to.eql(2); - }); - - it('should override any updates to pre-packaged rules if the user removes the exception list through the API but the new version of a rule has an exception list again', async () => { - await installPrePackagedRules(supertest); - - // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: - // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json - // This rule has an existing exceptions_list that we are going to use - const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one - - await supertest - .patch(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send({ rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', exceptions_list: [] }) - .expect(200); - - await downgradeImmutableRule(es, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - await installPrePackagedRules(supertest); - const immutableRuleSecondTime = await getRule( - supertest, - '9a1a2dae-0b5f-4c3d-8305-a268d404c306' - ); - - // We should have a length of 1 and it should be the same as our original before we tried to remove it using patch - expect(immutableRuleSecondTime.exceptions_list.length).to.eql(1); - expect(immutableRuleSecondTime.exceptions_list).to.eql(immutableRule.exceptions_list); - }); + }; - it('should merge back an exceptions_list if it was removed from the immutable rule through PATCH', async () => { - await installPrePackagedRules(supertest); - - const { id, list_id, namespace_type, type } = await createExceptionList( - supertest, - getCreateExceptionListMinimalSchemaMock() - ); - - // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: - // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json - // This rule has an existing exceptions_list that we are going to ensure does not stomp on our existing rule - const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one - - // remove the exception list and only have a single list that is not an endpoint_list - await supertest - .patch(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send({ - rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + const rule = await createRule(supertest, ruleWithException); + const expected: Partial = { + ...getSimpleRuleOutput(), exceptions_list: [ { id, @@ -254,70 +97,24 @@ export default ({ getService }: FtrProviderContext) => { type, }, ], - }) - .expect(200); - - await downgradeImmutableRule(es, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - await installPrePackagedRules(supertest); - const immutableRuleSecondTime = await getRule( - supertest, - '9a1a2dae-0b5f-4c3d-8305-a268d404c306' - ); - - expect(immutableRuleSecondTime.exceptions_list).to.eql([ - ...immutableRule.exceptions_list, - { - id, - list_id, - namespace_type, - type, - }, - ]); - }); - - it('should NOT add an extra exceptions_list that already exists on a rule during an upgrade', async () => { - await installPrePackagedRules(supertest); - - // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: - // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json - // This rule has an existing exceptions_list that we are going to ensure does not stomp on our existing rule - const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one - - await downgradeImmutableRule(es, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - await installPrePackagedRules(supertest); - - const immutableRuleSecondTime = await getRule( - supertest, - '9a1a2dae-0b5f-4c3d-8305-a268d404c306' - ); + }; + const bodyToCompare = removeServerGeneratedProperties(rule); + expect(bodyToCompare).to.eql(expected); + }); - // The installed rule should have both the original immutable exceptions list back and the - // new list the user added. - expect(immutableRuleSecondTime.exceptions_list).to.eql([...immutableRule.exceptions_list]); - }); + it('should create a single rule with an exception list and validate it ran successfully', async () => { + const { + body: { id, list_id, namespace_type, type }, + } = await supertest + .post(EXCEPTION_LIST_URL) + .set('kbn-xsrf', 'true') + .send(getCreateExceptionListMinimalSchemaMock()) + .expect(200); - it('should NOT allow updates to pre-packaged rules to overwrite existing exception based rules when the user adds an additional exception list', async () => { - await installPrePackagedRules(supertest); - - const { id, list_id, namespace_type, type } = await createExceptionList( - supertest, - getCreateExceptionListMinimalSchemaMock() - ); - - // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: - // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json - // This rule has an existing exceptions_list that we are going to ensure does not stomp on our existing rule - const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - - // add a second exceptions list as a user is allowed to add a second list to an immutable rule - await supertest - .patch(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send({ - rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + const ruleWithException: CreateRulesSchema = { + ...getSimpleRule(), + enabled: true, exceptions_list: [ - ...immutableRule.exceptions_list, { id, list_id, @@ -325,99 +122,16 @@ export default ({ getService }: FtrProviderContext) => { type, }, ], - }) - .expect(200); - - await downgradeImmutableRule(es, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - await installPrePackagedRules(supertest); - const immutableRuleSecondTime = await getRule( - supertest, - '9a1a2dae-0b5f-4c3d-8305-a268d404c306' - ); - - // It should be the same as what the user added originally - expect(immutableRuleSecondTime.exceptions_list).to.eql([ - ...immutableRule.exceptions_list, - { - id, - list_id, - namespace_type, - type, - }, - ]); - }); + }; - it('should not remove any exceptions added to a pre-packaged/immutable rule during an update if that rule has no existing exception lists', async () => { - await installPrePackagedRules(supertest); - - // Create a new exception list - const { id, list_id, namespace_type, type } = await createExceptionList( - supertest, - getCreateExceptionListMinimalSchemaMock() - ); - - // Rule id of "eb079c62-4481-4d6e-9643-3ca499df7aaa" is from the file: - // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json - // since this rule does not have existing exceptions_list that we are going to use for tests - const immutableRule = await getRule(supertest, 'eb079c62-4481-4d6e-9643-3ca499df7aaa'); - expect(immutableRule.exceptions_list.length).eql(0); // make sure we have no exceptions_list - - // add a second exceptions list as a user is allowed to add a second list to an immutable rule - await supertest - .patch(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send({ - rule_id: 'eb079c62-4481-4d6e-9643-3ca499df7aaa', - exceptions_list: [ - { - id, - list_id, - namespace_type, - type, - }, - ], - }) - .expect(200); - - await downgradeImmutableRule(es, 'eb079c62-4481-4d6e-9643-3ca499df7aaa'); - await installPrePackagedRules(supertest); - const immutableRuleSecondTime = await getRule( - supertest, - 'eb079c62-4481-4d6e-9643-3ca499df7aaa' - ); - - expect(immutableRuleSecondTime.exceptions_list).to.eql([ - { - id, - list_id, - namespace_type, - type, - }, - ]); - }); + const rule = await createRule(supertest, ruleWithException); + await waitForRuleSuccessOrStatus(supertest, rule.id); + const bodyToCompare = removeServerGeneratedProperties(rule); - it('should not change the immutable tags when adding a second exception list to an immutable rule through patch', async () => { - await installPrePackagedRules(supertest); - - const { id, list_id, namespace_type, type } = await createExceptionList( - supertest, - getCreateExceptionListMinimalSchemaMock() - ); - - // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: - // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json - // This rule has an existing exceptions_list that we are going to use - const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one - - // add a second exceptions list as a user is allowed to add a second list to an immutable rule - await supertest - .patch(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send({ - rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + const expected: Partial = { + ...getSimpleRuleOutput(), + enabled: true, exceptions_list: [ - ...immutableRule.exceptions_list, { id, list_id, @@ -425,52 +139,381 @@ export default ({ getService }: FtrProviderContext) => { type, }, ], - }) - .expect(200); + }; + expect(bodyToCompare).to.eql(expected); + }); + + it('should allow removing an exception list from an immutable rule through patch', async () => { + await installPrePackagedRules(supertest); + + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + // This rule has an existing exceptions_list that we are going to use + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one exceptions_list + + // remove the exceptions list as a user is allowed to remove it from an immutable rule + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', exceptions_list: [] }) + .expect(200); + + const immutableRuleSecondTime = await getRule( + supertest, + '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + expect(immutableRuleSecondTime.exceptions_list.length).to.eql(0); + }); + + it('should allow adding a second exception list to an immutable rule through patch', async () => { + await installPrePackagedRules(supertest); + + const { id, list_id, namespace_type, type } = await createExceptionList( + supertest, + getCreateExceptionListMinimalSchemaMock() + ); + + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + // This rule has an existing exceptions_list that we are going to use + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one + + // add a second exceptions list as a user is allowed to add a second list to an immutable rule + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + exceptions_list: [ + ...immutableRule.exceptions_list, + { + id, + list_id, + namespace_type, + type, + }, + ], + }) + .expect(200); + + const immutableRuleSecondTime = await getRule( + supertest, + '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + + expect(immutableRuleSecondTime.exceptions_list.length).to.eql(2); + }); + + it('should override any updates to pre-packaged rules if the user removes the exception list through the API but the new version of a rule has an exception list again', async () => { + await installPrePackagedRules(supertest); + + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + // This rule has an existing exceptions_list that we are going to use + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one + + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', exceptions_list: [] }) + .expect(200); + + await downgradeImmutableRule(es, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + await installPrePackagedRules(supertest); + const immutableRuleSecondTime = await getRule( + supertest, + '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + + // We should have a length of 1 and it should be the same as our original before we tried to remove it using patch + expect(immutableRuleSecondTime.exceptions_list.length).to.eql(1); + expect(immutableRuleSecondTime.exceptions_list).to.eql(immutableRule.exceptions_list); + }); - const body = await findImmutableRuleById(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - expect(body.data.length).to.eql(1); // should have only one length to the data set, otherwise we have duplicates or the tags were removed and that is incredibly bad. + it('should merge back an exceptions_list if it was removed from the immutable rule through PATCH', async () => { + await installPrePackagedRules(supertest); - const bodyToCompare = removeServerGeneratedProperties(body.data[0]); - expect(bodyToCompare.rule_id).to.eql(immutableRule.rule_id); // Rule id should not change with a a patch - expect(bodyToCompare.immutable).to.eql(immutableRule.immutable); // Immutable should always stay the same which is true and never flip to false. - expect(bodyToCompare.version).to.eql(immutableRule.version); // The version should never update on a patch + const { id, list_id, namespace_type, type } = await createExceptionList( + supertest, + getCreateExceptionListMinimalSchemaMock() + ); + + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + // This rule has an existing exceptions_list that we are going to ensure does not stomp on our existing rule + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one + + // remove the exception list and only have a single list that is not an endpoint_list + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + exceptions_list: [ + { + id, + list_id, + namespace_type, + type, + }, + ], + }) + .expect(200); + + await downgradeImmutableRule(es, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + await installPrePackagedRules(supertest); + const immutableRuleSecondTime = await getRule( + supertest, + '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + + expect(immutableRuleSecondTime.exceptions_list).to.eql([ + ...immutableRule.exceptions_list, + { + id, + list_id, + namespace_type, + type, + }, + ]); + }); + + it('should NOT add an extra exceptions_list that already exists on a rule during an upgrade', async () => { + await installPrePackagedRules(supertest); + + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + // This rule has an existing exceptions_list that we are going to ensure does not stomp on our existing rule + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one + + await downgradeImmutableRule(es, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + await installPrePackagedRules(supertest); + + const immutableRuleSecondTime = await getRule( + supertest, + '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + + // The installed rule should have both the original immutable exceptions list back and the + // new list the user added. + expect(immutableRuleSecondTime.exceptions_list).to.eql([ + ...immutableRule.exceptions_list, + ]); + }); + + it('should NOT allow updates to pre-packaged rules to overwrite existing exception based rules when the user adds an additional exception list', async () => { + await installPrePackagedRules(supertest); + + const { id, list_id, namespace_type, type } = await createExceptionList( + supertest, + getCreateExceptionListMinimalSchemaMock() + ); + + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + // This rule has an existing exceptions_list that we are going to ensure does not stomp on our existing rule + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + + // add a second exceptions list as a user is allowed to add a second list to an immutable rule + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + exceptions_list: [ + ...immutableRule.exceptions_list, + { + id, + list_id, + namespace_type, + type, + }, + ], + }) + .expect(200); + + await downgradeImmutableRule(es, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + await installPrePackagedRules(supertest); + const immutableRuleSecondTime = await getRule( + supertest, + '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + + // It should be the same as what the user added originally + expect(immutableRuleSecondTime.exceptions_list).to.eql([ + ...immutableRule.exceptions_list, + { + id, + list_id, + namespace_type, + type, + }, + ]); + }); + + it('should not remove any exceptions added to a pre-packaged/immutable rule during an update if that rule has no existing exception lists', async () => { + await installPrePackagedRules(supertest); + + // Create a new exception list + const { id, list_id, namespace_type, type } = await createExceptionList( + supertest, + getCreateExceptionListMinimalSchemaMock() + ); + + // Rule id of "eb079c62-4481-4d6e-9643-3ca499df7aaa" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json + // since this rule does not have existing exceptions_list that we are going to use for tests + const immutableRule = await getRule(supertest, 'eb079c62-4481-4d6e-9643-3ca499df7aaa'); + expect(immutableRule.exceptions_list.length).eql(0); // make sure we have no exceptions_list + + // add a second exceptions list as a user is allowed to add a second list to an immutable rule + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + rule_id: 'eb079c62-4481-4d6e-9643-3ca499df7aaa', + exceptions_list: [ + { + id, + list_id, + namespace_type, + type, + }, + ], + }) + .expect(200); + + await downgradeImmutableRule(es, 'eb079c62-4481-4d6e-9643-3ca499df7aaa'); + await installPrePackagedRules(supertest); + const immutableRuleSecondTime = await getRule( + supertest, + 'eb079c62-4481-4d6e-9643-3ca499df7aaa' + ); + + expect(immutableRuleSecondTime.exceptions_list).to.eql([ + { + id, + list_id, + namespace_type, + type, + }, + ]); + }); + + it('should not change the immutable tags when adding a second exception list to an immutable rule through patch', async () => { + await installPrePackagedRules(supertest); + + const { id, list_id, namespace_type, type } = await createExceptionList( + supertest, + getCreateExceptionListMinimalSchemaMock() + ); + + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + // This rule has an existing exceptions_list that we are going to use + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one + + // add a second exceptions list as a user is allowed to add a second list to an immutable rule + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + exceptions_list: [ + ...immutableRule.exceptions_list, + { + id, + list_id, + namespace_type, + type, + }, + ], + }) + .expect(200); + + const body = await findImmutableRuleById( + supertest, + '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + expect(body.data.length).to.eql(1); // should have only one length to the data set, otherwise we have duplicates or the tags were removed and that is incredibly bad. + + const bodyToCompare = removeServerGeneratedProperties(body.data[0]); + expect(bodyToCompare.rule_id).to.eql(immutableRule.rule_id); // Rule id should not change with a a patch + expect(bodyToCompare.immutable).to.eql(immutableRule.immutable); // Immutable should always stay the same which is true and never flip to false. + expect(bodyToCompare.version).to.eql(immutableRule.version); // The version should never update on a patch + }); + + it('should not change count of prepacked rules when adding a second exception list to an immutable rule through patch. If this fails, suspect the immutable tags are not staying on the rule correctly.', async () => { + await installPrePackagedRules(supertest); + + const { id, list_id, namespace_type, type } = await createExceptionList( + supertest, + getCreateExceptionListMinimalSchemaMock() + ); + + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json + // This rule has an existing exceptions_list that we are going to use + const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one + + // add a second exceptions list as a user is allowed to add a second list to an immutable rule + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + exceptions_list: [ + ...immutableRule.exceptions_list, + { + id, + list_id, + namespace_type, + type, + }, + ], + }) + .expect(200); + + const status = await getPrePackagedRulesStatus(supertest); + expect(status.rules_not_installed).to.eql(0); + }); }); - it('should not change count of prepacked rules when adding a second exception list to an immutable rule through patch. If this fails, suspect the immutable tags are not staying on the rule correctly.', async () => { - await installPrePackagedRules(supertest); - - const { id, list_id, namespace_type, type } = await createExceptionList( - supertest, - getCreateExceptionListMinimalSchemaMock() - ); - - // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: - // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json - // This rule has an existing exceptions_list that we are going to use - const immutableRule = await getRule(supertest, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); - expect(immutableRule.exceptions_list.length).greaterThan(0); // make sure we have at least one - - // add a second exceptions list as a user is allowed to add a second list to an immutable rule - await supertest - .patch(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send({ - rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', - exceptions_list: [ - ...immutableRule.exceptions_list, - { - id, - list_id, - namespace_type, - type, - }, - ], - }) - .expect(200); + describe('t1_analyst', () => { + const role = ROLES.t1_analyst; + + beforeEach(async () => { + await createUserAndRole(getService, role); + }); - const status = await getPrePackagedRulesStatus(supertest); - expect(status.rules_not_installed).to.eql(0); + afterEach(async () => { + await deleteUserAndRole(getService, role); + }); + + it('should NOT be able to create an exception list', async () => { + await supertestWithoutAuth + .post(EXCEPTION_LIST_ITEM_URL) + .auth(role, 'changeme') + .set('kbn-xsrf', 'true') + .send(getCreateExceptionListItemMinimalSchemaMock()) + .expect(403); + }); + + it('should NOT be able to create an exception list item', async () => { + await supertestWithoutAuth + .post(EXCEPTION_LIST_ITEM_URL) + .auth(role, 'changeme') + .set('kbn-xsrf', 'true') + .send(getCreateExceptionListItemMinimalSchemaMock()) + .expect(403); + }); }); describe('tests with auditbeat data', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts index 30d734b0e0262e..a735eba6693fed 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts @@ -30,10 +30,13 @@ import { getRuleForSignalTesting, getRuleForSignalTestingWithTimestampOverride, } from '../../utils'; +import { ROLES } from '../../../../plugins/security_solution/common/test'; +import { createUserAndRole, deleteUserAndRole } from '../roles_users_utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); describe('create_rules', () => { @@ -65,186 +68,209 @@ export default ({ getService }: FtrProviderContext) => { await esArchiver.unload('auditbeat/hosts'); }); - it('should create a single rule with a rule_id', async () => { - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send(getSimpleRule()) - .expect(200); - - const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(getSimpleRuleOutput()); - }); - - /* - This test is to ensure no future regressions introduced by the following scenario - a call to updateApiKey was invalidating the api key used by the - rule while the rule was executing, or even before it executed, - on the first rule run. - this pr https://github.com/elastic/kibana/pull/68184 - fixed this by finding the true source of a bug that required the manual - api key update, and removed the call to that function. - - When the api key is updated before / while the rule is executing, the alert - executor no longer has access to a service to update the rule status - saved object in Elasticsearch. Because of this, we cannot set the rule into - a 'failure' state, so the user ends up seeing 'going to run' as that is the - last status set for the rule before it erupts in an error that cannot be - recorded inside of the executor. - - This adds an e2e test for the backend to catch that in case - this pops up again elsewhere. - */ - it('should create a single rule with a rule_id and validate it ran successfully', async () => { - const simpleRule = getRuleForSignalTesting(['auditbeat-*']); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send(simpleRule) - .expect(200); - - await waitForRuleSuccessOrStatus(supertest, body.id); - - const { body: statusBody } = await supertest - .post(DETECTION_ENGINE_RULES_STATUS_URL) - .set('kbn-xsrf', 'true') - .send({ ids: [body.id] }) - .expect(200); + describe('elastic admin', () => { + it('should create a single rule with a rule_id', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); - expect(statusBody[body.id].current_status.status).to.eql('succeeded'); - }); + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutput()); + }); - it('should create a single rule with a rule_id and an index pattern that does not match anything available and fail the rule', async () => { - const simpleRule = getRuleForSignalTesting(['does-not-exist-*']); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send(simpleRule) - .expect(200); + /* + This test is to ensure no future regressions introduced by the following scenario + a call to updateApiKey was invalidating the api key used by the + rule while the rule was executing, or even before it executed, + on the first rule run. + this pr https://github.com/elastic/kibana/pull/68184 + fixed this by finding the true source of a bug that required the manual + api key update, and removed the call to that function. + + When the api key is updated before / while the rule is executing, the alert + executor no longer has access to a service to update the rule status + saved object in Elasticsearch. Because of this, we cannot set the rule into + a 'failure' state, so the user ends up seeing 'going to run' as that is the + last status set for the rule before it erupts in an error that cannot be + recorded inside of the executor. + + This adds an e2e test for the backend to catch that in case + this pops up again elsewhere. + */ + it('should create a single rule with a rule_id and validate it ran successfully', async () => { + const simpleRule = getRuleForSignalTesting(['auditbeat-*']); + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(simpleRule) + .expect(200); + + await waitForRuleSuccessOrStatus(supertest, body.id); + + const { body: statusBody } = await supertest + .post(DETECTION_ENGINE_RULES_STATUS_URL) + .set('kbn-xsrf', 'true') + .send({ ids: [body.id] }) + .expect(200); + + expect(statusBody[body.id].current_status.status).to.eql('succeeded'); + }); - await waitForRuleSuccessOrStatus(supertest, body.id, 'failed'); + it('should create a single rule with a rule_id and an index pattern that does not match anything available and fail the rule', async () => { + const simpleRule = getRuleForSignalTesting(['does-not-exist-*']); + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(simpleRule) + .expect(200); + + await waitForRuleSuccessOrStatus(supertest, body.id, 'failed'); + + const { body: statusBody } = await supertest + .post(DETECTION_ENGINE_RULES_STATUS_URL) + .set('kbn-xsrf', 'true') + .send({ ids: [body.id] }) + .expect(200); + + expect(statusBody[body.id].current_status.status).to.eql('failed'); + expect(statusBody[body.id].current_status.last_failure_message).to.eql( + 'The following index patterns did not match any indices: ["does-not-exist-*"]' + ); + }); - const { body: statusBody } = await supertest - .post(DETECTION_ENGINE_RULES_STATUS_URL) - .set('kbn-xsrf', 'true') - .send({ ids: [body.id] }) - .expect(200); + it('should create a single rule with a rule_id and an index pattern that does not match anything and an index pattern that does and the rule should be successful', async () => { + const simpleRule = getRuleForSignalTesting(['does-not-exist-*', 'auditbeat-*']); + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(simpleRule) + .expect(200); - expect(statusBody[body.id].current_status.status).to.eql('failed'); - expect(statusBody[body.id].current_status.last_failure_message).to.eql( - 'The following index patterns did not match any indices: ["does-not-exist-*"]' - ); - }); + await waitForRuleSuccessOrStatus(supertest, body.id, 'succeeded'); - it('should create a single rule with a rule_id and an index pattern that does not match anything and an index pattern that does and the rule should be successful', async () => { - const simpleRule = getRuleForSignalTesting(['does-not-exist-*', 'auditbeat-*']); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send(simpleRule) - .expect(200); + const { body: statusBody } = await supertest + .post(DETECTION_ENGINE_RULES_STATUS_URL) + .set('kbn-xsrf', 'true') + .send({ ids: [body.id] }) + .expect(200); - await waitForRuleSuccessOrStatus(supertest, body.id, 'succeeded'); - - const { body: statusBody } = await supertest - .post(DETECTION_ENGINE_RULES_STATUS_URL) - .set('kbn-xsrf', 'true') - .send({ ids: [body.id] }) - .expect(200); + expect(statusBody[body.id].current_status.status).to.eql('succeeded'); + }); - expect(statusBody[body.id].current_status.status).to.eql('succeeded'); - }); + it('should create a single rule without an input index', async () => { + const rule: CreateRulesSchema = { + name: 'Simple Rule Query', + description: 'Simple Rule Query', + enabled: true, + risk_score: 1, + rule_id: 'rule-1', + severity: 'high', + type: 'query', + query: 'user.name: root or user.name: admin', + }; + const expected = { + actions: [], + author: [], + created_by: 'elastic', + description: 'Simple Rule Query', + enabled: true, + false_positives: [], + from: 'now-6m', + immutable: false, + interval: '5m', + rule_id: 'rule-1', + language: 'kuery', + output_index: '.siem-signals-default', + max_signals: 100, + risk_score: 1, + risk_score_mapping: [], + name: 'Simple Rule Query', + query: 'user.name: root or user.name: admin', + references: [], + severity: 'high', + severity_mapping: [], + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + threat: [], + throttle: 'no_actions', + exceptions_list: [], + version: 1, + }; + + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(rule) + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(expected); + }); - it('should create a single rule without an input index', async () => { - const rule: CreateRulesSchema = { - name: 'Simple Rule Query', - description: 'Simple Rule Query', - enabled: true, - risk_score: 1, - rule_id: 'rule-1', - severity: 'high', - type: 'query', - query: 'user.name: root or user.name: admin', - }; - const expected = { - actions: [], - author: [], - created_by: 'elastic', - description: 'Simple Rule Query', - enabled: true, - false_positives: [], - from: 'now-6m', - immutable: false, - interval: '5m', - rule_id: 'rule-1', - language: 'kuery', - output_index: '.siem-signals-default', - max_signals: 100, - risk_score: 1, - risk_score_mapping: [], - name: 'Simple Rule Query', - query: 'user.name: root or user.name: admin', - references: [], - severity: 'high', - severity_mapping: [], - updated_by: 'elastic', - tags: [], - to: 'now', - type: 'query', - threat: [], - throttle: 'no_actions', - exceptions_list: [], - version: 1, - }; + it('should create a single rule without a rule_id', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRuleWithoutRuleId()) + .expect(200); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send(rule) - .expect(200); + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + }); - const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(expected); - }); + it('should create a single Machine Learning rule', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleMlRule()) + .expect(200); - it('should create a single rule without a rule_id', async () => { - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send(getSimpleRuleWithoutRuleId()) - .expect(200); + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(getSimpleMlRuleOutput()); + }); - const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body); - expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + it('should cause a 409 conflict if we attempt to create the same rule_id twice', async () => { + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(409); + + expect(body).to.eql({ + message: 'rule_id: "rule-1" already exists', + status_code: 409, + }); + }); }); - it('should create a single Machine Learning rule', async () => { - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send(getSimpleMlRule()) - .expect(200); + describe('t1_analyst', () => { + const role = ROLES.t1_analyst; - const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(getSimpleMlRuleOutput()); - }); - - it('should cause a 409 conflict if we attempt to create the same rule_id twice', async () => { - await supertest - .post(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send(getSimpleRule()) - .expect(200); + beforeEach(async () => { + await createUserAndRole(getService, role); + }); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_URL) - .set('kbn-xsrf', 'true') - .send(getSimpleRule()) - .expect(409); + afterEach(async () => { + await deleteUserAndRole(getService, role); + }); - expect(body).to.eql({ - message: 'rule_id: "rule-1" already exists', - status_code: 409, + it('should NOT be able to create a rule', async () => { + await supertestWithoutAuth + .post(DETECTION_ENGINE_RULES_URL) + .auth(role, 'changeme') + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(403); }); }); }); From f9f8562a539335d58b8a730ff46dacdcfc5b01a4 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Wed, 10 Feb 2021 21:27:33 -0700 Subject: [PATCH 02/24] Fixes track_total_hits in the body not having an effect when using search strategy (#91068) ## Summary Moves `track_total_hits` from body messages of our queries into the params section of our queries. Several of our `track_total_hits: false` were not taking effect and instead were being set to `track_total_hits: true` when being executed within the Kibana search strategy vs. previously when they were regular Elasticsearch queries and always took effect. When teams port over their searches to the search strategies provided by Kibana, they are required to move any and all `track_total_hits` from their `body` sections of their code into the `params` part of their code. The reason for this is that the search strategy maintains a backwards compatibility with earlier versions of searches before Elasticsearch introduced the `track_total_hits`. However, the code does not detect if you put the `track_total_hits` in your body, it only checks the params section and forces it to `true` if it is not found in the params section. If the search strategy does not see a `track_total_hits` within the params section of the query, it will force add one and that one will override any within the body of the query. For example, if you had a `track_total_hits` in your body and not in the params section, then search strategy would execute the query like so: ```ts GET someindex-*/_search?track_total_hits=true { // some query here "track_total_hits": false } ``` The forced parameter of `?track_total_hits=true` overrides the `track_total_hits: false` within the body of your query regardless of what the `track_total_hits` is set to and you always get the true. This bug has existed since 7.10.0 when we ported over queries to search strategy. You can see the code which sets this parameter if you do not here for master, 7.11, 7.10: https://github.com/elastic/kibana/blob/master/src/plugins/data/server/search/es_search/request_utils.ts#L31 https://github.com/elastic/kibana/blob/7.11/src/plugins/data/server/search/es_search/request_utils.ts#L31 https://github.com/elastic/kibana/blob/7.10/src/plugins/data/server/search/es_search/get_default_search_params.ts#L42 Comments about the behavior from 7.10: https://github.com/elastic/kibana/pull/75728#pullrequestreview-479367296 When running this code you can open dev tools and inspect the data and now notice when the total hits does not get set vs. before when it was getting set: before fix where total shows up for queries with `track_total_hits` in the body: event_view_before after fix where total no longer shows up for queries with `track_total_hits` moved to the params section: event_view_after ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../server/lib/hosts/query.detail_host.dsl.ts | 2 +- .../server/lib/hosts/query.hosts.dsl.ts | 2 +- .../server/lib/hosts/query.last_first_seen_host.dsl.ts | 2 +- .../factory/hosts/all/__mocks__/index.ts | 4 ++-- .../factory/hosts/all/query.all_hosts.dsl.ts | 2 +- .../factory/hosts/details/__mocks__/index.ts | 4 ++-- .../factory/hosts/details/query.host_details.dsl.ts | 2 +- .../query.hosts_kpi_authentications.dsl.ts | 2 +- .../hosts/kpi/hosts/query.hosts_kpi_hosts.dsl.ts | 2 +- .../kpi/unique_ips/query.hosts_kpi_unique_ips.dsl.ts | 2 +- .../factory/hosts/last_first_seen/__mocks__/index.ts | 4 ++-- .../last_first_seen/query.last_first_seen_host.dsl.ts | 2 +- .../factory/hosts/overview/__mocks__/index.ts | 4 ++-- .../factory/hosts/overview/query.overview_host.dsl.ts | 2 +- .../factory/matrix_histogram/__mocks__/index.ts | 10 +++++----- .../factory/matrix_histogram/alerts/__mocks__/index.ts | 2 +- .../alerts/query.alerts_histogram.dsl.ts | 2 +- .../matrix_histogram/anomalies/__mocks__/index.ts | 2 +- .../anomalies/query.anomalies_histogram.dsl.ts | 2 +- .../authentications/__mocks__/index.ts | 2 +- .../query.authentications_histogram.dsl.ts | 2 +- .../factory/matrix_histogram/events/__mocks__/index.ts | 6 +++--- .../events/query.events_histogram.dsl.ts | 2 +- .../factory/network/details/__mocks__/index.ts | 4 ++-- .../network/details/query.details_network.dsl.ts | 2 +- .../network/kpi/dns/query.network_kpi_dns.dsl.ts | 2 +- .../query.network_kpi_network_events.dsl.ts | 2 +- .../query.network_kpi_tls_handshakes.dsl.ts | 2 +- .../unique_flows/query.network_kpi_unique_flows.dsl.ts | 2 +- .../query.network_kpi_unique_private_ips.dsl.ts | 2 +- .../factory/network/overview/__mocks__/index.ts | 2 +- .../network/overview/query.overview_network.dsl.ts | 2 +- .../factory/network/tls/__mocks__/index.ts | 4 ++-- .../factory/network/tls/query.tls_network.dsl.ts | 2 +- .../factory/network/users/__mocks__/index.ts | 4 ++-- .../factory/network/users/query.users_network.dsl.ts | 2 +- .../query.events_last_event_time.dsl.ts | 6 +++--- 37 files changed, 52 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/hosts/query.detail_host.dsl.ts b/x-pack/plugins/security_solution/server/lib/hosts/query.detail_host.dsl.ts index 2c1c39259aae38..4dd5a86e46bf61 100644 --- a/x-pack/plugins/security_solution/server/lib/hosts/query.detail_host.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/hosts/query.detail_host.dsl.ts @@ -39,13 +39,13 @@ export const buildHostOverviewQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { ...buildFieldsTermAggregation(esFields.filter((field) => !['@timestamp'].includes(field))), }, query: { bool: { filter } }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts b/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts index d83b4c9f9fd80f..16c53aa6a85eba 100644 --- a/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts @@ -44,6 +44,7 @@ export const buildHostsQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { @@ -72,7 +73,6 @@ export const buildHostsQuery = ({ }, query: { bool: { filter } }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/hosts/query.last_first_seen_host.dsl.ts b/x-pack/plugins/security_solution/server/lib/hosts/query.last_first_seen_host.dsl.ts index e7e9ec48fc5344..a047be8ed26745 100644 --- a/x-pack/plugins/security_solution/server/lib/hosts/query.last_first_seen_host.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/hosts/query.last_first_seen_host.dsl.ts @@ -19,6 +19,7 @@ export const buildLastFirstSeenHostQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { @@ -27,7 +28,6 @@ export const buildLastFirstSeenHostQuery = ({ }, query: { bool: { filter } }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts index 96082ee1b4be81..b6a5435a0e0461 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts @@ -621,6 +621,7 @@ export const formattedSearchStrategyResponse = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { docvalue_fields: mockOptions.docValueFields, aggregations: { @@ -656,7 +657,6 @@ export const formattedSearchStrategyResponse = { }, }, size: 0, - track_total_hits: false, }, }, null, @@ -782,6 +782,7 @@ export const mockBuckets: HostAggEsItem = { export const expectedDsl = { allowNoIndices: true, + track_total_hits: false, body: { aggregations: { host_count: { cardinality: { field: 'host.name' } }, @@ -817,7 +818,6 @@ export const expectedDsl = { }, docvalue_fields: mockOptions.docValueFields, size: 0, - track_total_hits: false, }, ignoreUnavailable: true, index: [ diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts index 5196eaa2574441..08c97117949784 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts @@ -43,6 +43,7 @@ export const buildHostsQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { @@ -71,7 +72,6 @@ export const buildHostsQuery = ({ }, query: { bool: { filter } }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/__mocks__/index.ts index 9c3380191507c2..7561682e070fc5 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/__mocks__/index.ts @@ -1311,6 +1311,7 @@ export const formattedSearchStrategyResponse = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { host_architecture: { @@ -1387,7 +1388,6 @@ export const formattedSearchStrategyResponse = { }, }, size: 0, - track_total_hits: false, }, }, null, @@ -1410,6 +1410,7 @@ export const expectedDsl = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { host_architecture: { @@ -1645,6 +1646,5 @@ export const expectedDsl = { }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/query.host_details.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/query.host_details.dsl.ts index fa720825bb3f9b..f340e4d9056662 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/query.host_details.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/query.host_details.dsl.ts @@ -36,13 +36,13 @@ export const buildHostDetailsQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { ...buildFieldsTermAggregation(esFields.filter((field) => !['@timestamp'].includes(field))), }, query: { bool: { filter } }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/authentications/query.hosts_kpi_authentications.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/authentications/query.hosts_kpi_authentications.dsl.ts index 455eeed5ba80f2..a5c82688e01ba2 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/authentications/query.hosts_kpi_authentications.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/authentications/query.hosts_kpi_authentications.dsl.ts @@ -41,6 +41,7 @@ export const buildHostsKpiAuthenticationsQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: false, body: { aggs: { authentication_success: { @@ -94,7 +95,6 @@ export const buildHostsKpiAuthenticationsQuery = ({ }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/hosts/query.hosts_kpi_hosts.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/hosts/query.hosts_kpi_hosts.dsl.ts index 21e862e3858d05..0e0cbd8a2649d0 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/hosts/query.hosts_kpi_hosts.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/hosts/query.hosts_kpi_hosts.dsl.ts @@ -30,6 +30,7 @@ export const buildHostsKpiHostsQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { hosts: { @@ -57,7 +58,6 @@ export const buildHostsKpiHostsQuery = ({ }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/unique_ips/query.hosts_kpi_unique_ips.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/unique_ips/query.hosts_kpi_unique_ips.dsl.ts index 815a2644355eb1..a702982ab8253d 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/unique_ips/query.hosts_kpi_unique_ips.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/unique_ips/query.hosts_kpi_unique_ips.dsl.ts @@ -30,6 +30,7 @@ export const buildHostsKpiUniqueIpsQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { unique_source_ips: { @@ -75,7 +76,6 @@ export const buildHostsKpiUniqueIpsQuery = ({ }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/__mocks__/index.ts index b43727e977a126..0cad31bffb2a19 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/__mocks__/index.ts @@ -69,6 +69,7 @@ export const formattedSearchStrategyResponse = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { firstSeen: { min: { field: '@timestamp' } }, @@ -76,7 +77,6 @@ export const formattedSearchStrategyResponse = { }, query: { bool: { filter: [{ term: { 'host.name': 'siem-kibana' } }] } }, size: 0, - track_total_hits: false, }, }, null, @@ -100,6 +100,7 @@ export const expectedDsl = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { firstSeen: { min: { field: '@timestamp' } }, @@ -107,6 +108,5 @@ export const expectedDsl = { }, query: { bool: { filter: [{ term: { 'host.name': 'siem-kibana' } }] } }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts index f14727f94b30a7..d601a5905dd6e2 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts @@ -20,6 +20,7 @@ export const buildFirstLastSeenHostQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { @@ -28,7 +29,6 @@ export const buildFirstLastSeenHostQuery = ({ }, query: { bool: { filter } }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/__mocks__/index.ts index 1105914fa5d7fc..987754420430d6 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/__mocks__/index.ts @@ -127,6 +127,7 @@ export const formattedSearchStrategyResponse = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { auditd_count: { filter: { term: { 'event.module': 'auditd' } } }, @@ -299,7 +300,6 @@ export const formattedSearchStrategyResponse = { }, }, size: 0, - track_total_hits: false, }, }, null, @@ -339,6 +339,7 @@ export const expectedDsl = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { auditd_count: { filter: { term: { 'event.module': 'auditd' } } }, @@ -511,6 +512,5 @@ export const expectedDsl = { }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.overview_host.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.overview_host.dsl.ts index d7c9b2b25f35e6..2c237ab75bcbbe 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.overview_host.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.overview_host.dsl.ts @@ -31,6 +31,7 @@ export const buildOverviewHostQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { auditd_count: { @@ -289,7 +290,6 @@ export const buildOverviewHostQuery = ({ }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/__mocks__/index.ts index b43bd7e378fa65..07ae64bc63f19a 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/__mocks__/index.ts @@ -42,6 +42,7 @@ export const formattedAlertsSearchStrategyResponse: MatrixHistogramStrategyRespo ], allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: { alertsGroup: { @@ -113,7 +114,6 @@ export const formattedAlertsSearchStrategyResponse: MatrixHistogramStrategyRespo }, }, size: 0, - track_total_hits: true, }, }, null, @@ -127,6 +127,7 @@ export const formattedAlertsSearchStrategyResponse: MatrixHistogramStrategyRespo export const expectedDsl = { allowNoIndices: true, + track_total_hits: false, body: { aggregations: { host_count: { cardinality: { field: 'host.name' } }, @@ -161,7 +162,6 @@ export const expectedDsl = { }, }, size: 0, - track_total_hits: false, }, ignoreUnavailable: true, index: [ @@ -208,6 +208,7 @@ export const formattedAnomaliesSearchStrategyResponse: MatrixHistogramStrategyRe ], allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggs: { anomalyActionGroup: { @@ -258,7 +259,6 @@ export const formattedAnomaliesSearchStrategyResponse: MatrixHistogramStrategyRe }, }, size: 0, - track_total_hits: true, }, }, null, @@ -390,6 +390,7 @@ export const formattedAuthenticationsSearchStrategyResponse: MatrixHistogramStra ], allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: { eventActionGroup: { @@ -429,7 +430,6 @@ export const formattedAuthenticationsSearchStrategyResponse: MatrixHistogramStra }, }, size: 0, - track_total_hits: true, }, }, null, @@ -956,6 +956,7 @@ export const formattedEventsSearchStrategyResponse: MatrixHistogramStrategyRespo ], allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: { eventActionGroup: { @@ -994,7 +995,6 @@ export const formattedEventsSearchStrategyResponse: MatrixHistogramStrategyRespo }, }, size: 0, - track_total_hits: true, }, }, null, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/alerts/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/alerts/__mocks__/index.ts index 74b7e8b18028bc..86006c31554477 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/alerts/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/alerts/__mocks__/index.ts @@ -36,6 +36,7 @@ export const expectedDsl = { ], allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: { alertsGroup: { @@ -104,6 +105,5 @@ export const expectedDsl = { }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/alerts/query.alerts_histogram.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/alerts/query.alerts_histogram.dsl.ts index 7dd867b19f284a..54ee066b64119b 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/alerts/query.alerts_histogram.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/alerts/query.alerts_histogram.dsl.ts @@ -85,6 +85,7 @@ export const buildAlertsHistogramQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: getHistogramAggregation(), query: { @@ -93,7 +94,6 @@ export const buildAlertsHistogramQuery = ({ }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/anomalies/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/anomalies/__mocks__/index.ts index 561e2fb1f00585..81da78a132084a 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/anomalies/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/anomalies/__mocks__/index.ts @@ -36,6 +36,7 @@ export const expectedDsl = { ], allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggs: { anomalyActionGroup: { @@ -83,6 +84,5 @@ export const expectedDsl = { }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/anomalies/query.anomalies_histogram.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/anomalies/query.anomalies_histogram.dsl.ts index 34e5831b52b92f..78fc0a30d04778 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/anomalies/query.anomalies_histogram.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/anomalies/query.anomalies_histogram.dsl.ts @@ -66,6 +66,7 @@ export const buildAnomaliesHistogramQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggs: getHistogramAggregation(), query: { @@ -74,7 +75,6 @@ export const buildAnomaliesHistogramQuery = ({ }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/authentications/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/authentications/__mocks__/index.ts index 169f1569adc37f..5cf667a0085fa7 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/authentications/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/authentications/__mocks__/index.ts @@ -35,6 +35,7 @@ export const expectedDsl = { ], allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: { eventActionGroup: { @@ -74,6 +75,5 @@ export const expectedDsl = { }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/authentications/query.authentications_histogram.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/authentications/query.authentications_histogram.dsl.ts index 4a208f2ab341e8..8661fff574b4a7 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/authentications/query.authentications_histogram.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/authentications/query.authentications_histogram.dsl.ts @@ -78,6 +78,7 @@ export const buildAuthenticationsHistogramQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: getHistogramAggregation(), query: { @@ -86,7 +87,6 @@ export const buildAuthenticationsHistogramQuery = ({ }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/events/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/events/__mocks__/index.ts index 312c0d528f20ba..0bf1118835414a 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/events/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/events/__mocks__/index.ts @@ -40,6 +40,7 @@ export const expectedDsl = { ], allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: { eventActionGroup: { @@ -78,7 +79,6 @@ export const expectedDsl = { }, }, size: 0, - track_total_hits: true, }, }; @@ -94,6 +94,7 @@ export const expectedThresholdDsl = { ], allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: { eventActionGroup: { @@ -132,7 +133,6 @@ export const expectedThresholdDsl = { }, }, size: 0, - track_total_hits: true, }, }; @@ -148,6 +148,7 @@ export const expectedThresholdMissingFieldDsl = { ], allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: { eventActionGroup: { @@ -187,6 +188,5 @@ export const expectedThresholdMissingFieldDsl = { }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/events/query.events_histogram.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/events/query.events_histogram.dsl.ts index aa1e1d47c87c6a..04b428f9de89e2 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/events/query.events_histogram.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/events/query.events_histogram.dsl.ts @@ -97,6 +97,7 @@ export const buildEventsHistogramQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { aggregations: getHistogramAggregation(), query: { @@ -105,7 +106,6 @@ export const buildEventsHistogramQuery = ({ }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts index 46d9c23321a8fc..1cea4c3eb63bcf 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts @@ -314,6 +314,7 @@ export const formattedSearchStrategyResponse = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { docvalue_fields: mockOptions.docValueFields, aggs: { @@ -390,7 +391,6 @@ export const formattedSearchStrategyResponse = { }, query: { bool: { should: [] } }, size: 0, - track_total_hits: false, }, }, null, @@ -455,6 +455,7 @@ export const expectedDsl = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggs: { source: { @@ -521,6 +522,5 @@ export const expectedDsl = { docvalue_fields: mockOptions.docValueFields, query: { bool: { should: [] } }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts index b20de12624db47..d1d0c44d9b61b0 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts @@ -106,6 +106,7 @@ export const buildNetworkDetailsQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggs: { @@ -119,7 +120,6 @@ export const buildNetworkDetailsQuery = ({ }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/dns/query.network_kpi_dns.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/dns/query.network_kpi_dns.dsl.ts index 0c4379fa89fd8c..1d1aa50cc3ee2b 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/dns/query.network_kpi_dns.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/dns/query.network_kpi_dns.dsl.ts @@ -58,6 +58,7 @@ export const buildDnsQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { query: { bool: { @@ -65,7 +66,6 @@ export const buildDnsQuery = ({ }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/network_events/query.network_kpi_network_events.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/network_events/query.network_kpi_network_events.dsl.ts index 7222519bb0ac0d..3d5607c8b443a8 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/network_events/query.network_kpi_network_events.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/network_events/query.network_kpi_network_events.dsl.ts @@ -32,6 +32,7 @@ export const buildNetworkEventsQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { query: { bool: { @@ -39,7 +40,6 @@ export const buildNetworkEventsQuery = ({ }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/tls_handshakes/query.network_kpi_tls_handshakes.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/tls_handshakes/query.network_kpi_tls_handshakes.dsl.ts index d8d27a8ad7e35e..0a826938e95b86 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/tls_handshakes/query.network_kpi_tls_handshakes.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/tls_handshakes/query.network_kpi_tls_handshakes.dsl.ts @@ -58,6 +58,7 @@ export const buildTlsHandshakeQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: true, body: { query: { bool: { @@ -65,7 +66,6 @@ export const buildTlsHandshakeQuery = ({ }, }, size: 0, - track_total_hits: true, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/unique_flows/query.network_kpi_unique_flows.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/unique_flows/query.network_kpi_unique_flows.dsl.ts index 13a404ec3720b7..ec8de30cfff852 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/unique_flows/query.network_kpi_unique_flows.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/unique_flows/query.network_kpi_unique_flows.dsl.ts @@ -32,6 +32,7 @@ export const buildUniqueFlowsQuery = ({ index: defaultIndex, allowNoIndices: true, ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { unique_flow_id: { @@ -46,7 +47,6 @@ export const buildUniqueFlowsQuery = ({ }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/unique_private_ips/query.network_kpi_unique_private_ips.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/unique_private_ips/query.network_kpi_unique_private_ips.dsl.ts index e12ccf5b7889b6..590e7117826d73 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/unique_private_ips/query.network_kpi_unique_private_ips.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/kpi/unique_private_ips/query.network_kpi_unique_private_ips.dsl.ts @@ -87,6 +87,7 @@ export const buildUniquePrivateIpsQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { ...getAggs('source'), @@ -98,7 +99,6 @@ export const buildUniquePrivateIpsQuery = ({ }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/__mocks__/index.ts index 79ad6489558de9..fcb30be7a403d6 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/__mocks__/index.ts @@ -111,6 +111,7 @@ export const formattedSearchStrategyResponse = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { unique_flow_count: { filter: { term: { type: 'flow' } } }, @@ -182,7 +183,6 @@ export const formattedSearchStrategyResponse = { }, }, size: 0, - track_total_hits: false, }, }, null, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/query.overview_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/query.overview_network.dsl.ts index c5e2892bd9f823..7e35ae2fd4308f 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/query.overview_network.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/overview/query.overview_network.dsl.ts @@ -31,6 +31,7 @@ export const buildOverviewNetworkQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { aggregations: { unique_flow_count: { @@ -99,7 +100,6 @@ export const buildOverviewNetworkQuery = ({ }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/__mocks__/index.ts index 5028e4a27c93e3..16750acc5adeed 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/__mocks__/index.ts @@ -69,6 +69,7 @@ export const formattedSearchStrategyResponse = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggs: { count: { cardinality: { field: 'tls.server.hash.sha1' } }, @@ -99,7 +100,6 @@ export const formattedSearchStrategyResponse = { }, }, size: 0, - track_total_hits: false, }, }, null, @@ -123,6 +123,7 @@ export const expectedDsl = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggs: { count: { cardinality: { field: 'tls.server.hash.sha1' } }, @@ -153,6 +154,5 @@ export const expectedDsl = { }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts index ff5fe20f685f1d..be60b33ae2d226 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts @@ -78,6 +78,7 @@ export const buildNetworkTlsQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { aggs: { ...getAggs(querySize, sort), @@ -88,7 +89,6 @@ export const buildNetworkTlsQuery = ({ }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/users/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/users/__mocks__/index.ts index 252f165f11ad9b..3837afabe57993 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/users/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/users/__mocks__/index.ts @@ -129,6 +129,7 @@ export const formattedSearchStrategyResponse = { 'winlogbeat-*', ], ignoreUnavailable: true, + track_total_hits: false, body: { aggs: { user_count: { cardinality: { field: 'user.name' } }, @@ -160,7 +161,6 @@ export const formattedSearchStrategyResponse = { }, }, size: 0, - track_total_hits: false, }, }, null, @@ -174,6 +174,7 @@ export const formattedSearchStrategyResponse = { export const expectedDsl = { allowNoIndices: true, + track_total_hits: false, body: { aggs: { user_count: { cardinality: { field: 'user.name' } }, @@ -205,7 +206,6 @@ export const expectedDsl = { }, }, size: 0, - track_total_hits: false, }, ignoreUnavailable: true, index: [ diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/users/query.users_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/users/query.users_network.dsl.ts index 57cb6093ae355c..2b02b25292a32c 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/users/query.users_network.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/users/query.users_network.dsl.ts @@ -37,6 +37,7 @@ export const buildUsersQuery = ({ allowNoIndices: true, index: defaultIndex, ignoreUnavailable: true, + track_total_hits: false, body: { aggs: { user_count: { @@ -84,7 +85,6 @@ export const buildUsersQuery = ({ }, }, size: 0, - track_total_hits: false, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts index 1e7b531d7fcf1c..ccc156af84922e 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts @@ -40,6 +40,7 @@ export const buildLastEventTimeQuery = ({ allowNoIndices: true, index: indicesToQuery.network, ignoreUnavailable: true, + track_total_hits: false, body: { ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { @@ -47,7 +48,6 @@ export const buildLastEventTimeQuery = ({ }, query: { bool: { should: getIpDetailsFilter(details.ip) } }, size: 0, - track_total_hits: false, }, }; } @@ -58,6 +58,7 @@ export const buildLastEventTimeQuery = ({ allowNoIndices: true, index: indicesToQuery.hosts, ignoreUnavailable: true, + track_total_hits: false, body: { ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { @@ -65,7 +66,6 @@ export const buildLastEventTimeQuery = ({ }, query: { bool: { filter: getHostDetailsFilter(details.hostName) } }, size: 0, - track_total_hits: false, }, }; } @@ -76,6 +76,7 @@ export const buildLastEventTimeQuery = ({ allowNoIndices: true, index: indicesToQuery[indexKey], ignoreUnavailable: true, + track_total_hits: false, body: { ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { @@ -83,7 +84,6 @@ export const buildLastEventTimeQuery = ({ }, query: { match_all: {} }, size: 0, - track_total_hits: false, }, }; default: From d9abaa180b15ef72f18a9ad03a4395c0a9601873 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Wed, 10 Feb 2021 23:34:36 -0600 Subject: [PATCH 03/24] Don't clean when running e2e tests (#91057) I don't think this is necessary, and since it's run before `bootstrap`, the Bazel tools aren't installed so it fails silently. Example: https://apm-ci.elastic.co/blue/organizations/jenkins/apm-ui%2Fapm-ui-e2e-tests-mbp%2FPR-89647/detail/PR-89647/21/pipeline/124/ Should fix APM E2E failures. --- x-pack/plugins/apm/e2e/ci/prepare-kibana.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/apm/e2e/ci/prepare-kibana.sh b/x-pack/plugins/apm/e2e/ci/prepare-kibana.sh index f383dd6d16f7ff..9e6198bcc526d9 100755 --- a/x-pack/plugins/apm/e2e/ci/prepare-kibana.sh +++ b/x-pack/plugins/apm/e2e/ci/prepare-kibana.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash -set -ex +set -e E2E_DIR=x-pack/plugins/apm/e2e -echo "1/2 Install dependencies ..." +echo "1/2 Install dependencies..." # shellcheck disable=SC1091 source src/dev/ci_setup/setup_env.sh true -yarn kbn clean && yarn kbn bootstrap +yarn kbn bootstrap -echo "2/2 Start Kibana ..." +echo "2/2 Start Kibana..." ## Might help to avoid FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory export NODE_OPTIONS="--max-old-space-size=4096" nohup node ./scripts/kibana --no-base-path --no-watch --dev --no-dev-config --config ${E2E_DIR}/ci/kibana.e2e.yml > ${E2E_DIR}/kibana.log 2>&1 & From c0a974aa0e048af7c5829172c41aa32d860961c4 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 10 Feb 2021 21:38:06 -0800 Subject: [PATCH 04/24] [ts/build_ts_refs] add support for --clean flag (#91060) Co-authored-by: spalger --- scripts/build_ts_refs.js | 2 +- src/dev/typescript/build_refs.ts | 35 ------------ src/dev/typescript/build_ts_refs.ts | 24 ++++++++ src/dev/typescript/build_ts_refs_cli.ts | 37 ++++++++++++ src/dev/typescript/concurrent_map.ts | 28 ++++++++++ src/dev/typescript/index.ts | 1 + src/dev/typescript/project.ts | 15 +---- src/dev/typescript/run_type_check_cli.ts | 4 +- src/dev/typescript/ts_configfile.ts | 71 ++++++++++++++++++++++++ 9 files changed, 166 insertions(+), 51 deletions(-) delete mode 100644 src/dev/typescript/build_refs.ts create mode 100644 src/dev/typescript/build_ts_refs.ts create mode 100644 src/dev/typescript/build_ts_refs_cli.ts create mode 100644 src/dev/typescript/concurrent_map.ts create mode 100644 src/dev/typescript/ts_configfile.ts diff --git a/scripts/build_ts_refs.js b/scripts/build_ts_refs.js index 3222e0e90797bb..a4ee6ec491ef15 100644 --- a/scripts/build_ts_refs.js +++ b/scripts/build_ts_refs.js @@ -7,4 +7,4 @@ */ require('../src/setup_node_env'); -require('../src/dev/typescript/build_refs').runBuildRefs(); +require('../src/dev/typescript').runBuildRefsCli(); diff --git a/src/dev/typescript/build_refs.ts b/src/dev/typescript/build_refs.ts deleted file mode 100644 index 77d6eb2abc6126..00000000000000 --- a/src/dev/typescript/build_refs.ts +++ /dev/null @@ -1,35 +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 execa from 'execa'; -import { run, ToolingLog } from '@kbn/dev-utils'; - -export async function buildAllRefs(log: ToolingLog) { - await buildRefs(log, 'tsconfig.refs.json'); -} - -async function buildRefs(log: ToolingLog, projectPath: string) { - try { - log.debug(`Building TypeScript projects refs for ${projectPath}...`); - await execa(require.resolve('typescript/bin/tsc'), ['-b', projectPath, '--pretty']); - } catch (e) { - log.error(e); - process.exit(1); - } -} - -export async function runBuildRefs() { - run( - async ({ log }) => { - await buildAllRefs(log); - }, - { - description: 'Build TypeScript projects', - } - ); -} diff --git a/src/dev/typescript/build_ts_refs.ts b/src/dev/typescript/build_ts_refs.ts new file mode 100644 index 00000000000000..2e25827996e453 --- /dev/null +++ b/src/dev/typescript/build_ts_refs.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 Path from 'path'; + +import execa from 'execa'; +import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; + +export const REF_CONFIG_PATHS = [Path.resolve(REPO_ROOT, 'tsconfig.refs.json')]; + +export async function buildAllTsRefs(log: ToolingLog) { + for (const path of REF_CONFIG_PATHS) { + const relative = Path.relative(REPO_ROOT, path); + log.debug(`Building TypeScript projects refs for ${relative}...`); + await execa(require.resolve('typescript/bin/tsc'), ['-b', relative, '--pretty'], { + cwd: REPO_ROOT, + }); + } +} diff --git a/src/dev/typescript/build_ts_refs_cli.ts b/src/dev/typescript/build_ts_refs_cli.ts new file mode 100644 index 00000000000000..1f7bf18b5012d9 --- /dev/null +++ b/src/dev/typescript/build_ts_refs_cli.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 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 { run } from '@kbn/dev-utils'; +import del from 'del'; + +import { buildAllTsRefs, REF_CONFIG_PATHS } from './build_ts_refs'; +import { getOutputsDeep } from './ts_configfile'; +import { concurrentMap } from './concurrent_map'; + +export async function runBuildRefsCli() { + run( + async ({ log, flags }) => { + if (flags.clean) { + const outDirs = getOutputsDeep(REF_CONFIG_PATHS); + log.info('deleting', outDirs.length, 'ts output directories'); + await concurrentMap(100, outDirs, (outDir) => del(outDir)); + } + + await buildAllTsRefs(log); + }, + { + description: 'Build TypeScript projects', + flags: { + boolean: ['clean'], + }, + log: { + defaultLevel: 'debug', + }, + } + ); +} diff --git a/src/dev/typescript/concurrent_map.ts b/src/dev/typescript/concurrent_map.ts new file mode 100644 index 00000000000000..793630ab85a553 --- /dev/null +++ b/src/dev/typescript/concurrent_map.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 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 * as Rx from 'rxjs'; +import { mergeMap, toArray, map } from 'rxjs/operators'; +import { lastValueFrom } from '@kbn/std'; + +export async function concurrentMap( + concurrency: number, + arr: T[], + fn: (item: T, i: number) => Promise +): Promise { + return await lastValueFrom( + Rx.from(arr).pipe( + // execute items in parallel based on concurrency + mergeMap(async (item, index) => ({ index, result: await fn(item, index) }), concurrency), + // collect the results into an array + toArray(), + // sort items back into order and return array of just results + map((list) => list.sort((a, b) => a.index - b.index).map(({ result }) => result)) + ) + ); +} diff --git a/src/dev/typescript/index.ts b/src/dev/typescript/index.ts index 934c032152fafd..34ecd76a994db4 100644 --- a/src/dev/typescript/index.ts +++ b/src/dev/typescript/index.ts @@ -11,3 +11,4 @@ export { filterProjectsByFlag } from './projects'; export { getTsProjectForAbsolutePath } from './get_ts_project_for_absolute_path'; export { execInProjects } from './exec_in_projects'; export { runTypeCheckCli } from './run_type_check_cli'; +export * from './build_ts_refs_cli'; diff --git a/src/dev/typescript/project.ts b/src/dev/typescript/project.ts index 4eb8036d9c9024..8d92284e496379 100644 --- a/src/dev/typescript/project.ts +++ b/src/dev/typescript/project.ts @@ -6,14 +6,13 @@ * Side Public License, v 1. */ -import { readFileSync } from 'fs'; import { basename, dirname, relative, resolve } from 'path'; import { IMinimatch, Minimatch } from 'minimatch'; -import { parseConfigFileTextToJson } from 'typescript'; - import { REPO_ROOT } from '@kbn/utils'; +import { parseTsConfig } from './ts_configfile'; + function makeMatchers(directory: string, patterns: string[]) { return patterns.map( (pattern) => @@ -23,16 +22,6 @@ function makeMatchers(directory: string, patterns: string[]) { ); } -function parseTsConfig(path: string) { - const { error, config } = parseConfigFileTextToJson(path, readFileSync(path, 'utf8')); - - if (error) { - throw error; - } - - return config; -} - function testMatchers(matchers: IMinimatch[], path: string) { return matchers.some((matcher) => matcher.match(path)); } diff --git a/src/dev/typescript/run_type_check_cli.ts b/src/dev/typescript/run_type_check_cli.ts index 50f725891753bc..f95c230f44b9e4 100644 --- a/src/dev/typescript/run_type_check_cli.ts +++ b/src/dev/typescript/run_type_check_cli.ts @@ -13,7 +13,7 @@ import getopts from 'getopts'; import { execInProjects } from './exec_in_projects'; import { filterProjectsByFlag } from './projects'; -import { buildAllRefs } from './build_refs'; +import { buildAllTsRefs } from './build_ts_refs'; export async function runTypeCheckCli() { const extraFlags: string[] = []; @@ -69,7 +69,7 @@ export async function runTypeCheckCli() { process.exit(); } - await buildAllRefs(log); + await buildAllTsRefs(log); const tscArgs = [ // composite project cannot be used with --noEmit diff --git a/src/dev/typescript/ts_configfile.ts b/src/dev/typescript/ts_configfile.ts new file mode 100644 index 00000000000000..7998edcf80bcf1 --- /dev/null +++ b/src/dev/typescript/ts_configfile.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Fs from 'fs'; +import Path from 'path'; + +import { parseConfigFileTextToJson } from 'typescript'; + +// yes, this is just `any`, but I'm hoping that TypeScript will give us some help here eventually +type TsConfigFile = ReturnType['config']; + +export function parseTsConfig(tsConfigPath: string): TsConfigFile { + const { error, config } = parseConfigFileTextToJson( + tsConfigPath, + Fs.readFileSync(tsConfigPath, 'utf8') + ); + + if (error) { + throw error; + } + + return config; +} + +export function getOutputsDeep(tsConfigPaths: string[]) { + const tsConfigs = new Map(); + + const read = (path: string) => { + const cached = tsConfigs.get(path); + if (cached) { + return cached; + } + + const config = parseTsConfig(path); + tsConfigs.set(path, config); + return config; + }; + + const outputDirs: string[] = []; + const seen = new Set(); + + const traverse = (path: string) => { + const config = read(path); + if (seen.has(config)) { + return; + } + seen.add(config); + + const dirname = Path.dirname(path); + const relativeOutDir: string | undefined = config.compilerOptions?.outDir; + if (relativeOutDir) { + outputDirs.push(Path.resolve(dirname, relativeOutDir)); + } + + const refs: undefined | Array<{ path: string }> = config.references; + for (const ref of refs ?? []) { + traverse(Path.resolve(dirname, ref.path)); + } + }; + + for (const path of tsConfigPaths) { + traverse(path); + } + + return outputDirs; +} From 7d9b4ed9dc1bf8aa0d2a286c68bf9af6ca8041de Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 11 Feb 2021 11:11:45 +0300 Subject: [PATCH 05/24] [Create index pattern] Can't create single character index without wildcard (#90919) --- .../components/step_index_pattern/step_index_pattern.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx index 22a7d297f60a69..d7038a754fc6be 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx @@ -193,12 +193,12 @@ export class StepIndexPattern extends Component target.setSelectionRange(1, 1)); } else { - if (query === '*' && appendedWildcard) { + if (['', '*'].includes(query) && appendedWildcard) { query = ''; this.setState({ appendedWildcard: false }); } From 57d9dd1419b480fa83c5a802ef9760b7b76446b4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Feb 2021 09:18:48 +0100 Subject: [PATCH 06/24] Update dependency @elastic/charts to v24.5.1 (#89822) Updates @elastic/charts to 24.5.1 with some Kibana related fixes: - align tooltip z-index to EUI tooltip z-index - external tooltip legend extra value sync - legend: hierarchical legend order should follow the tree paths fix #84307 Co-authored-by: Renovate Bot Co-authored-by: Marco Vettorello Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- .../vis_type_xy/public/config/get_config.ts | 2 +- .../vis_type_xy/public/utils/domain.ts | 2 +- .../public/utils/render_all_series.tsx | 2 +- test/functional/apps/visualize/_area_chart.ts | 9 ++- .../apps/visualize/_point_series_options.ts | 20 +++--- .../apps/visualize/_vertical_bar_chart.ts | 68 +++++++++++++------ .../_vertical_bar_chart_nontimeindex.ts | 64 +++++++++++------ .../RumDashboard/Charts/PageLoadDistChart.tsx | 2 +- .../RumDashboard/Charts/PageViewsChart.tsx | 2 +- .../render_function.test.tsx | 13 ++-- .../pie_visualization/render_helpers.test.ts | 20 ++++-- .../explorer/swimlane_container.tsx | 4 +- .../__snapshots__/donut_chart.test.tsx.snap | 12 +++- .../common/charts/duration_line_bar_list.tsx | 3 +- yarn.lock | 14 ++-- 16 files changed, 157 insertions(+), 82 deletions(-) diff --git a/package.json b/package.json index 0fa8ef31ab251d..9ddb37b60021d6 100644 --- a/package.json +++ b/package.json @@ -351,7 +351,7 @@ "@cypress/webpack-preprocessor": "^5.5.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "24.4.0", + "@elastic/charts": "24.5.1", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", diff --git a/src/plugins/vis_type_xy/public/config/get_config.ts b/src/plugins/vis_type_xy/public/config/get_config.ts index 444428ce8ad3b2..b19366fc22dbb9 100644 --- a/src/plugins/vis_type_xy/public/config/get_config.ts +++ b/src/plugins/vis_type_xy/public/config/get_config.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { ScaleContinuousType } from '@elastic/charts/dist/scales'; +import { ScaleContinuousType } from '@elastic/charts'; import { Datatable } from '../../../expressions/public'; import { BUCKET_TYPES } from '../../../data/public'; diff --git a/src/plugins/vis_type_xy/public/utils/domain.ts b/src/plugins/vis_type_xy/public/utils/domain.ts index 6c01e97d678d12..a59b2fd20cb5a5 100644 --- a/src/plugins/vis_type_xy/public/utils/domain.ts +++ b/src/plugins/vis_type_xy/public/utils/domain.ts @@ -21,7 +21,7 @@ export const getXDomain = (params: Aspect['params']): DomainRange => { const minInterval = (params as DateHistogramParams | HistogramParams)?.interval ?? undefined; if ((params as DateHistogramParams).date) { - const bounds = getTimefilter().getBounds(); + const bounds = getTimefilter().getActiveBounds(); if (bounds) { return { diff --git a/src/plugins/vis_type_xy/public/utils/render_all_series.tsx b/src/plugins/vis_type_xy/public/utils/render_all_series.tsx index 7f80f3772e7ca8..585c42eccf9d5b 100644 --- a/src/plugins/vis_type_xy/public/utils/render_all_series.tsx +++ b/src/plugins/vis_type_xy/public/utils/render_all_series.tsx @@ -17,8 +17,8 @@ import { SeriesName, Accessor, AccessorFn, + ColorVariant, } from '@elastic/charts'; -import { ColorVariant } from '@elastic/charts/dist/utils/commons'; import { DatatableRow } from '../../../expressions/public'; diff --git a/test/functional/apps/visualize/_area_chart.ts b/test/functional/apps/visualize/_area_chart.ts index 05fbdc2e0c2837..9b8abc7ae60a8a 100644 --- a/test/functional/apps/visualize/_area_chart.ts +++ b/test/functional/apps/visualize/_area_chart.ts @@ -96,7 +96,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show correct chart', async function () { const xAxisLabels = await PageObjects.visChart.getExpectedValue( ['2015-09-20 00:00', '2015-09-21 00:00', '2015-09-22 00:00', '2015-09-23 00:00'], - ['2015-09-19 12:00', '2015-09-20 12:00', '2015-09-21 12:00', '2015-09-22 12:00'] + [ + '2015-09-20 00:00', + '2015-09-20 12:00', + '2015-09-21 00:00', + '2015-09-21 12:00', + '2015-09-22 00:00', + '2015-09-22 12:00', + ] ); const yAxisLabels = await PageObjects.visChart.getExpectedValue( ['0', '200', '400', '600', '800', '1,000', '1,200', '1,400', '1,600'], diff --git a/test/functional/apps/visualize/_point_series_options.ts b/test/functional/apps/visualize/_point_series_options.ts index a24be29f876eac..09f9694ea9474c 100644 --- a/test/functional/apps/visualize/_point_series_options.ts +++ b/test/functional/apps/visualize/_point_series_options.ts @@ -169,8 +169,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.toggleGridCategoryLines(); await PageObjects.visEditor.clickGo(); const gridLines = await PageObjects.visChart.getGridLines(); - const expectedCount = await PageObjects.visChart.getExpectedValue(9, 5); - expect(gridLines.length).to.be(expectedCount); + // FLAKY relaxing as depends on chart size/browser size and produce differences between local and CI + // The objective here is to check whenever the grid lines are rendered, not the exact quantity + expect(gridLines.length).to.be.greaterThan(0); gridLines.forEach((gridLine) => { expect(gridLine.y).to.be(0); }); @@ -181,8 +182,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.toggleGridCategoryLines(); await PageObjects.visEditor.clickGo(); const gridLines = await PageObjects.visChart.getGridLines(); - const expectedCount = await PageObjects.visChart.getExpectedValue(9, 8); - expect(gridLines.length).to.be(expectedCount); + // FLAKY relaxing as depends on chart size/browser size and produce differences between local and CI + // The objective here is to check whenever the grid lines are rendered, not the exact quantity + expect(gridLines.length).to.be.greaterThan(0); gridLines.forEach((gridLine) => { expect(gridLine.x).to.be(0); }); @@ -267,7 +269,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show round labels in default timezone', async function () { const expectedLabels = await PageObjects.visChart.getExpectedValue( ['2015-09-20 00:00', '2015-09-21 00:00', '2015-09-22 00:00'], - ['2015-09-19 12:00', '2015-09-20 12:00', '2015-09-21 12:00', '2015-09-22 12:00'] + ['2015-09-20 00:00', '2015-09-20 18:00', '2015-09-21 12:00', '2015-09-22 06:00'] ); await initChart(); const labels = await PageObjects.visChart.getXAxisLabels(); @@ -277,13 +279,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show round labels in different timezone', async function () { const expectedLabels = await PageObjects.visChart.getExpectedValue( ['2015-09-20 00:00', '2015-09-21 00:00', '2015-09-22 00:00'], - [ - '2015-09-19 12:00', - '2015-09-20 06:00', - '2015-09-21 00:00', - '2015-09-21 18:00', - '2015-09-22 12:00', - ] + ['2015-09-19 18:00', '2015-09-20 12:00', '2015-09-21 06:00', '2015-09-22 00:00'] ); await kibanaServer.uiSettings.update({ 'dateFormat:tz': 'America/Phoenix' }); diff --git a/test/functional/apps/visualize/_vertical_bar_chart.ts b/test/functional/apps/visualize/_vertical_bar_chart.ts index 5a3442a1b9fb5b..5dafdd5b040106 100644 --- a/test/functional/apps/visualize/_vertical_bar_chart.ts +++ b/test/functional/apps/visualize/_vertical_bar_chart.ts @@ -441,7 +441,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visChart.waitForVisualizationRenderingStabilized(); await PageObjects.visEditor.clickGo(); - const expectedEntries = ['200', '404', '503']; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + ['200', '404', '503'], + ['503', '404', '200'] // sorting aligned with rendered geometries + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); @@ -451,7 +454,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.selectCustomSortMetric(3, 'Min', 'bytes'); await PageObjects.visEditor.clickGo(); - const expectedEntries = ['404', '200', '503']; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + ['404', '200', '503'], + ['503', '200', '404'] // sorting aligned with rendered geometries + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); @@ -484,23 +490,42 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visChart.waitForVisualizationRenderingStabilized(); await PageObjects.visEditor.clickGo(); - const expectedEntries = [ - '200 - win 8', - '200 - win xp', - '200 - ios', - '200 - osx', - '200 - win 7', - '404 - ios', - '503 - ios', - '503 - osx', - '503 - win 7', - '503 - win 8', - '503 - win xp', - '404 - osx', - '404 - win 7', - '404 - win 8', - '404 - win xp', - ]; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + [ + '200 - win 8', + '200 - win xp', + '200 - ios', + '200 - osx', + '200 - win 7', + '404 - ios', + '503 - ios', + '503 - osx', + '503 - win 7', + '503 - win 8', + '503 - win xp', + '404 - osx', + '404 - win 7', + '404 - win 8', + '404 - win xp', + ], + [ + '404 - win xp', + '404 - win 8', + '404 - win 7', + '404 - osx', + '503 - win xp', + '503 - win 8', + '503 - win 7', + '503 - osx', + '503 - ios', + '404 - ios', + '200 - win 7', + '200 - osx', + '200 - ios', + '200 - win xp', + '200 - win 8', + ] + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); @@ -511,7 +536,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.toggleDisabledAgg(3); await PageObjects.visEditor.clickGo(); - const expectedEntries = ['win 8', 'win xp', 'ios', 'osx', 'win 7']; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + ['win 8', 'win xp', 'ios', 'osx', 'win 7'], + ['win 7', 'osx', 'ios', 'win xp', 'win 8'] + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); diff --git a/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.ts b/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.ts index adb16167cf2ad5..34f401b5afff63 100644 --- a/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.ts +++ b/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.ts @@ -213,7 +213,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); await PageObjects.header.waitUntilLoadingHasFinished(); - const expectedEntries = ['200', '404', '503']; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + ['200', '404', '503'], + ['503', '404', '200'] // sorting aligned with rendered geometries + ); + const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); @@ -239,23 +243,42 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); await PageObjects.header.waitUntilLoadingHasFinished(); - const expectedEntries = [ - '200 - win 8', - '200 - win xp', - '200 - ios', - '200 - osx', - '200 - win 7', - '404 - ios', - '503 - ios', - '503 - osx', - '503 - win 7', - '503 - win 8', - '503 - win xp', - '404 - osx', - '404 - win 7', - '404 - win 8', - '404 - win xp', - ]; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + [ + '200 - win 8', + '200 - win xp', + '200 - ios', + '200 - osx', + '200 - win 7', + '404 - ios', + '503 - ios', + '503 - osx', + '503 - win 7', + '503 - win 8', + '503 - win xp', + '404 - osx', + '404 - win 7', + '404 - win 8', + '404 - win xp', + ], + [ + '404 - win xp', + '404 - win 8', + '404 - win 7', + '404 - osx', + '503 - win xp', + '503 - win 8', + '503 - win 7', + '503 - osx', + '503 - ios', + '404 - ios', + '200 - win 7', + '200 - osx', + '200 - ios', + '200 - win xp', + '200 - win 8', + ] + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); @@ -265,7 +288,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); await PageObjects.header.waitUntilLoadingHasFinished(); - const expectedEntries = ['win 8', 'win xp', 'ios', 'osx', 'win 7']; + const expectedEntries = await PageObjects.visChart.getExpectedValue( + ['win 8', 'win xp', 'ios', 'osx', 'win 7'], + ['win 7', 'osx', 'ios', 'win xp', 'win 8'] + ); const legendEntries = await PageObjects.visChart.getLegendEntries(); expect(legendEntries).to.eql(expectedEntries); }); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx index e195e173868492..589cce26b4d8ca 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx @@ -20,12 +20,12 @@ import { DARK_THEME, LIGHT_THEME, Fit, + Position, } from '@elastic/charts'; import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT, } from '@elastic/eui/dist/eui_charts_theme'; -import { Position } from '@elastic/charts/dist/utils/commons'; import styled from 'styled-components'; import { PercentileAnnotations } from '../PageLoadDistribution/PercentileAnnotations'; import { I18LABELS } from '../translations'; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx index dfa69ded00e869..6be2eada6a9ec9 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx @@ -17,8 +17,8 @@ import { SeriesNameFn, Settings, timeFormatter, + Position, } from '@elastic/charts'; -import { Position } from '@elastic/charts/dist/utils/commons'; import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT, diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index a9a12a87f9ec36..e18878ea064ef9 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -6,12 +6,14 @@ */ import React from 'react'; -import { Partition, SeriesIdentifier, Settings } from '@elastic/charts'; import { + Partition, + SeriesIdentifier, + Settings, NodeColorAccessor, ShapeTreeNode, -} from '@elastic/charts/dist/chart_types/partition_chart/layout/types/viewmodel_types'; -import { HierarchyOfArrays } from '@elastic/charts/dist/chart_types/partition_chart/layout/utils/group_by_rollup'; + HierarchyOfArrays, +} from '@elastic/charts'; import { shallow } from 'enzyme'; import { LensMultiTable } from '../types'; import { PieComponent } from './render_function'; @@ -214,7 +216,10 @@ describe('PieVisualization component', () => { const defaultArgs = getDefaultArgs(); const component = shallow(); component.find(Settings).first().prop('onElementClick')!([ - [[{ groupByRollup: 6, value: 6 }], {} as SeriesIdentifier], + [ + [{ groupByRollup: 6, value: 6, depth: 1, path: [], sortIndex: 1 }], + {} as SeriesIdentifier, + ], ]); expect(defaultArgs.onClickValue.mock.calls[0][0]).toMatchInlineSnapshot(` diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts index 4af7b1649b3e47..6e40b07af67133 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts @@ -64,7 +64,13 @@ describe('render helpers', () => { { a: 'Foo', b: 6 }, ], }; - expect(getFilterContext([{ groupByRollup: 'Test', value: 100 }], ['a'], table)).toEqual({ + expect( + getFilterContext( + [{ groupByRollup: 'Test', value: 100, depth: 1, path: [], sortIndex: 1 }], + ['a'], + table + ) + ).toEqual({ data: [ { row: 1, @@ -90,7 +96,13 @@ describe('render helpers', () => { { a: 'Foo', b: 'Three', c: 6 }, ], }; - expect(getFilterContext([{ groupByRollup: 'Test', value: 100 }], ['a', 'b'], table)).toEqual({ + expect( + getFilterContext( + [{ groupByRollup: 'Test', value: 100, depth: 1, path: [], sortIndex: 1 }], + ['a', 'b'], + table + ) + ).toEqual({ data: [ { row: 1, @@ -119,8 +131,8 @@ describe('render helpers', () => { expect( getFilterContext( [ - { groupByRollup: 'Test', value: 100 }, - { groupByRollup: 'Two', value: 5 }, + { groupByRollup: 'Test', value: 100, depth: 1, path: [], sortIndex: 1 }, + { groupByRollup: 'Two', value: 5, depth: 1, path: [], sortIndex: 1 }, ], ['a', 'b'], table diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index 57de12a7be9744..8deffa15cd6bd7 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -24,12 +24,12 @@ import { ElementClickListener, TooltipValue, HeatmapSpec, + TooltipSettings, + HeatmapBrushEvent, } from '@elastic/charts'; import moment from 'moment'; -import { HeatmapBrushEvent } from '@elastic/charts/dist/chart_types/heatmap/layout/types/config_types'; import { i18n } from '@kbn/i18n'; -import { TooltipSettings } from '@elastic/charts/dist/specs/settings'; import { SwimLanePagination } from './swimlane_pagination'; import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../common'; diff --git a/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap index 967d078bde2105..238ce6c3f9ceec 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap @@ -39,6 +39,7 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "white", "opacity": 1, "radius": 2, + "shape": "circle", "strokeWidth": 1, "visible": false, }, @@ -134,6 +135,7 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "white", "opacity": 1, "radius": 2, + "shape": "circle", "strokeWidth": 1, "visible": true, }, @@ -170,12 +172,17 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "#F5F5F5", "visible": true, }, - "line": Object { + "crossLine": Object { "dash": Array [ 5, 5, ], - "stroke": "#777", + "stroke": "#98A2B3", + "strokeWidth": 1, + "visible": true, + }, + "line": Object { + "stroke": "#98A2B3", "strokeWidth": 1, "visible": true, }, @@ -196,6 +203,7 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "white", "opacity": 1, "radius": 2, + "shape": "circle", "strokeWidth": 1, "visible": true, }, diff --git a/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx b/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx index 2c6ad63b51e7b2..3d0fefbd083f81 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx @@ -7,8 +7,7 @@ import React from 'react'; import moment from 'moment'; -import { AnnotationTooltipFormatter, RectAnnotation } from '@elastic/charts'; -import { RectAnnotationDatum } from '@elastic/charts/dist/chart_types/xy_chart/utils/specs'; +import { AnnotationTooltipFormatter, RectAnnotation, RectAnnotationDatum } from '@elastic/charts'; import { AnnotationTooltip } from './annotation_tooltip'; import { ANOMALY_SEVERITY, diff --git a/yarn.lock b/yarn.lock index 319025b3aab77e..c6a12fa353ecce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2146,10 +2146,10 @@ dependencies: object-hash "^1.3.0" -"@elastic/charts@24.4.0": - version "24.4.0" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.4.0.tgz#217f55540f48a8f59c49250781d99c36110b2544" - integrity sha512-8dxDEs0g1mV4MjPgIArAmdDQDKjH8EitCLh8/Rouv8kkxvdXnL86VkSHpUbZNK9zPAecArwHBSkyCBZNmbqT2A== +"@elastic/charts@24.5.1": + version "24.5.1" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.5.1.tgz#4757721b0323b15412c92d696dd76fdef9b963f8" + integrity sha512-eHJna3xyHREaSfTRb+3/34EmyoINopH6yP9KReakXRb0jW8DD4n9IkbPFwpVN3uXQ6ND2x1ObA0ZzLPSLCPozg== dependencies: "@popperjs/core" "^2.4.0" chroma-js "^2.1.0" @@ -2161,7 +2161,6 @@ d3-scale "^1.0.7" d3-shape "^1.3.4" newtype-ts "^0.2.4" - path2d-polyfill "^0.4.2" prop-types "^15.7.2" re-reselect "^3.4.0" react-redux "^7.1.0" @@ -22841,11 +22840,6 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -path2d-polyfill@^0.4.2: - version "0.4.2" - resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-0.4.2.tgz#594d3103838ef6b9dd4a7fd498fe9a88f1f28531" - integrity sha512-JSeAnUfkFjl+Ml/EZL898ivMSbGHrOH63Mirx5EQ1ycJiryHDmj1Q7Are+uEPvenVGCUN9YbolfGfyUewJfJEg== - pathval@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" From 9870ade9713dbcf9cf39819adfeaaadb432e0988 Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Thu, 11 Feb 2021 09:33:51 +0100 Subject: [PATCH 07/24] [Fleet] Reduce permissions. (#90302) * Reduce permissions. * Change permissions back. * Reducing permissions on fleet_enroll role - 'write', 'create_index' -> 'auto_configure', 'create_doc' * Remove indices:admin/auto_create from privileges. --- .../fleet/server/services/api_keys/index.ts | 13 ++-------- x-pack/plugins/fleet/server/services/setup.ts | 13 ++-------- .../apis/agents_setup.ts | 13 ++-------- .../fleet_api_integration/apis/fleet_setup.ts | 24 ++++--------------- 4 files changed, 10 insertions(+), 53 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/api_keys/index.ts b/x-pack/plugins/fleet/server/services/api_keys/index.ts index 65051163c78c3a..911cb700dd56b5 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/index.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/index.ts @@ -22,17 +22,8 @@ export async function generateOutputApiKey( cluster: ['monitor'], index: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - '.logs-endpoint.diagnostic.collection-*', - '.ds-.logs-endpoint.diagnostic.collection-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + privileges: ['auto_configure', 'create_doc'], }, ], }, diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index f19ad4e7fe417d..6c8f24e7995742 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -192,17 +192,8 @@ async function putFleetRole(callCluster: CallESAsCurrentUser) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - '.logs-endpoint.diagnostic.collection-*', - '.ds-.logs-endpoint.diagnostic.collection-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + privileges: ['auto_configure', 'create_doc'], }, ], }, diff --git a/x-pack/test/fleet_api_integration/apis/agents_setup.ts b/x-pack/test/fleet_api_integration/apis/agents_setup.ts index c1abdfab566b99..20112afdf76a43 100644 --- a/x-pack/test/fleet_api_integration/apis/agents_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/agents_setup.ts @@ -60,17 +60,8 @@ export default function (providerContext: FtrProviderContext) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - '.logs-endpoint.diagnostic.collection-*', - '.ds-.logs-endpoint.diagnostic.collection-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + privileges: ['auto_configure', 'create_doc'], allow_restricted_indices: false, }, ], diff --git a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts index 2c15cddc81ea1d..31d620cd34931f 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts @@ -62,15 +62,8 @@ export default function (providerContext: FtrProviderContext) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*'], + privileges: ['create_doc', 'indices:admin/auto_create'], allow_restricted_indices: false, }, ], @@ -101,17 +94,8 @@ export default function (providerContext: FtrProviderContext) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - '.logs-endpoint.diagnostic.collection-*', - '.ds-.logs-endpoint.diagnostic.collection-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + privileges: ['auto_configure', 'create_doc'], allow_restricted_indices: false, }, ], From 570dcc07b52245e44cd4b43679e839673b7c7eaa Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Thu, 11 Feb 2021 10:12:24 +0100 Subject: [PATCH 08/24] Implement custom global header banner (#87438) * first draft * update plugin list * fix tsproject * update bundle limits file * remove unused start dep * adapt imports * POC of footer banner * update styles, mostly * plug banner to uiSettings * adding some unit tests * add tests on sort_fields * cleanup sums in sass mixins * some self review stuff * update generated doc * add tests for color field * update chrome header test snapshots * retrieve license info from the server * switch from uiSettings to plugin config * update plugin list description * update default colors * NIT * add markdown support * fix banner overlap in fullscreen mode * change banner height to 32px * change banner's font size to 14 * delete unused uiSettings --- .stylelintrc | 1 + docs/developer/plugin-list.asciidoc | 4 + .../kibana-plugin-core-public.chromestart.md | 1 + ...core-public.chromestart.setheaderbanner.md | 28 ++ ...in-core-public.chromeuserbanner.content.md | 11 + ...ana-plugin-core-public.chromeuserbanner.md | 19 + ...kibana-plugin-core-public.doclinksstart.md | 2 +- .../core/public/kibana-plugin-core-public.md | 1 + ...ana-plugin-core-public.uisettingsparams.md | 1 + ...ugin-core-public.uisettingsparams.order.md | 15 + ...ibana-plugin-core-public.uisettingstype.md | 2 +- ...ana-plugin-core-server.uisettingsparams.md | 1 + ...ugin-core-server.uisettingsparams.order.md | 15 + ...ibana-plugin-core-server.uisettingstype.md | 2 +- packages/kbn-optimizer/limits.yml | 1 + src/core/public/_mixins.scss | 43 ++ src/core/public/_variables.scss | 5 + src/core/public/chrome/chrome_service.mock.ts | 3 + src/core/public/chrome/chrome_service.tsx | 242 ++-------- src/core/public/chrome/index.ts | 20 +- src/core/public/chrome/types.ts | 241 ++++++++++ .../header/__snapshots__/header.test.tsx.snap | 107 ++++- src/core/public/chrome/ui/header/_banner.scss | 22 + src/core/public/chrome/ui/header/_index.scss | 2 +- .../public/chrome/ui/header/header.test.tsx | 4 +- src/core/public/chrome/ui/header/header.tsx | 21 +- .../public/chrome/ui/header/header_badge.tsx | 2 +- .../chrome/ui/header/header_breadcrumbs.tsx | 2 +- .../chrome/ui/header/header_help_menu.tsx | 2 +- .../chrome/ui/header/header_top_banner.tsx | 34 ++ src/core/public/core_app/styles/_mixins.scss | 8 + src/core/public/core_system.ts | 1 - src/core/public/index.scss | 1 + src/core/public/index.ts | 2 + src/core/public/public.api.md | 10 +- src/core/public/rendering/_base.scss | 32 +- .../public/rendering/rendering_service.tsx | 23 +- src/core/server/server.api.md | 3 +- src/core/types/ui_settings.ts | 10 +- .../management_app/advanced_settings.tsx | 20 +- .../field/__snapshots__/field.test.tsx.snap | 413 ++++++++++++++++++ .../components/field/field.test.tsx | 46 ++ .../management_app/components/field/field.tsx | 12 + .../public/management_app/lib/index.ts | 1 + .../management_app/lib/sort_fields.test.ts | 56 +++ .../public/management_app/lib/sort_fields.ts | 31 ++ .../management_app/lib/to_editable_config.ts | 6 +- .../public/management_app/types.ts | 1 + .../application/components/discover.scss | 4 +- .../public/application/components/_home.scss | 4 +- .../public/application/components/home.js | 3 + .../public/components/_overview.scss | 4 +- x-pack/.i18nrc.json | 3 +- x-pack/plugins/banners/README.md | 38 ++ x-pack/plugins/banners/common/index.ts | 8 + x-pack/plugins/banners/common/types.ts | 20 + x-pack/plugins/banners/jest.config.js | 12 + x-pack/plugins/banners/kibana.json | 11 + .../banners/public/components/banner.scss | 7 + .../banners/public/components/banner.tsx | 33 ++ .../banners/public/components/index.ts | 8 + .../banners/public/get_banner_info.test.ts | 35 ++ .../plugins/banners/public/get_banner_info.ts | 13 + x-pack/plugins/banners/public/index.ts | 12 + .../banners/public/plugin.test.mocks.ts | 11 + x-pack/plugins/banners/public/plugin.test.tsx | 86 ++++ x-pack/plugins/banners/public/plugin.tsx | 44 ++ x-pack/plugins/banners/public/types.ts | 12 + x-pack/plugins/banners/server/config.ts | 42 ++ x-pack/plugins/banners/server/index.ts | 12 + x-pack/plugins/banners/server/plugin.ts | 33 ++ x-pack/plugins/banners/server/routes/index.ts | 14 + x-pack/plugins/banners/server/routes/info.ts | 36 ++ x-pack/plugins/banners/server/types.ts | 15 + x-pack/plugins/banners/server/utils.test.ts | 26 ++ x-pack/plugins/banners/server/utils.ts | 12 + x-pack/plugins/banners/tsconfig.json | 22 + x-pack/plugins/maps/public/_main.scss | 10 +- .../painless_lab/public/styles/_index.scss | 7 +- .../public/application/_app.scss | 8 +- x-pack/test/tsconfig.json | 1 + x-pack/tsconfig.json | 1 + 82 files changed, 1855 insertions(+), 282 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md create mode 100644 src/core/public/_mixins.scss create mode 100644 src/core/public/chrome/types.ts create mode 100644 src/core/public/chrome/ui/header/_banner.scss create mode 100644 src/core/public/chrome/ui/header/header_top_banner.tsx create mode 100644 src/plugins/advanced_settings/public/management_app/lib/sort_fields.test.ts create mode 100644 src/plugins/advanced_settings/public/management_app/lib/sort_fields.ts create mode 100644 x-pack/plugins/banners/README.md create mode 100644 x-pack/plugins/banners/common/index.ts create mode 100644 x-pack/plugins/banners/common/types.ts create mode 100644 x-pack/plugins/banners/jest.config.js create mode 100644 x-pack/plugins/banners/kibana.json create mode 100644 x-pack/plugins/banners/public/components/banner.scss create mode 100644 x-pack/plugins/banners/public/components/banner.tsx create mode 100644 x-pack/plugins/banners/public/components/index.ts create mode 100644 x-pack/plugins/banners/public/get_banner_info.test.ts create mode 100644 x-pack/plugins/banners/public/get_banner_info.ts create mode 100644 x-pack/plugins/banners/public/index.ts create mode 100644 x-pack/plugins/banners/public/plugin.test.mocks.ts create mode 100644 x-pack/plugins/banners/public/plugin.test.tsx create mode 100644 x-pack/plugins/banners/public/plugin.tsx create mode 100644 x-pack/plugins/banners/public/types.ts create mode 100644 x-pack/plugins/banners/server/config.ts create mode 100644 x-pack/plugins/banners/server/index.ts create mode 100644 x-pack/plugins/banners/server/plugin.ts create mode 100644 x-pack/plugins/banners/server/routes/index.ts create mode 100644 x-pack/plugins/banners/server/routes/info.ts create mode 100644 x-pack/plugins/banners/server/types.ts create mode 100644 x-pack/plugins/banners/server/utils.test.ts create mode 100644 x-pack/plugins/banners/server/utils.ts create mode 100644 x-pack/plugins/banners/tsconfig.json diff --git a/.stylelintrc b/.stylelintrc index 29c1f4b552b48a..26431cfee6f1d7 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -32,6 +32,7 @@ rules: - function - return - for + - at-root comment-no-empty: true no-duplicate-at-import-rules: true no-duplicate-selectors: true diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 613f2d0fbf20cf..5564b4cdcf79da 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -301,6 +301,10 @@ which will load the visualization's editor. |To access an elasticsearch instance that has live data you have two options: +|{kib-repo}blob/{branch}/x-pack/plugins/banners/README.md[banners] +|Allow to add a header banner that will be displayed on every page of the Kibana application + + |{kib-repo}blob/{branch}/x-pack/plugins/beats_management/readme.md[beatsManagement] |Notes: Failure to have auth enabled in Kibana will make for a broken UI. UI-based errors not yet in place diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.md index 663b326193de56..2d465745c436b5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.md @@ -67,6 +67,7 @@ core.chrome.setHelpExtension(elem => { | [setBreadcrumbs(newBreadcrumbs)](./kibana-plugin-core-public.chromestart.setbreadcrumbs.md) | Override the current set of breadcrumbs | | [setBreadcrumbsAppendExtension(breadcrumbsAppendExtension)](./kibana-plugin-core-public.chromestart.setbreadcrumbsappendextension.md) | Mount an element next to the last breadcrumb | | [setCustomNavLink(newCustomNavLink)](./kibana-plugin-core-public.chromestart.setcustomnavlink.md) | Override the current set of custom nav link | +| [setHeaderBanner(headerBanner)](./kibana-plugin-core-public.chromestart.setheaderbanner.md) | Set the banner that will appear on top of the chrome header. | | [setHelpExtension(helpExtension)](./kibana-plugin-core-public.chromestart.sethelpextension.md) | Override the current set of custom help content | | [setHelpSupportUrl(url)](./kibana-plugin-core-public.chromestart.sethelpsupporturl.md) | Override the default support URL shown in the help menu | | [setIsVisible(isVisible)](./kibana-plugin-core-public.chromestart.setisvisible.md) | Set the temporary visibility for the chrome. This does nothing if the chrome is hidden by default and should be used to hide the chrome for things like full-screen modes with an exit button. | diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md new file mode 100644 index 00000000000000..02a2fa65ed4781 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [setHeaderBanner](./kibana-plugin-core-public.chromestart.setheaderbanner.md) + +## ChromeStart.setHeaderBanner() method + +Set the banner that will appear on top of the chrome header. + +Signature: + +```typescript +setHeaderBanner(headerBanner?: ChromeUserBanner): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| headerBanner | ChromeUserBanner | | + +Returns: + +`void` + +## Remarks + +Using `undefined` when invoking this API will remove the banner. + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md new file mode 100644 index 00000000000000..7a77fdc6223de9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) > [content](./kibana-plugin-core-public.chromeuserbanner.content.md) + +## ChromeUserBanner.content property + +Signature: + +```typescript +content: MountPoint; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md new file mode 100644 index 00000000000000..8617c5c4d2b12f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) + +## ChromeUserBanner interface + + +Signature: + +```typescript +export interface ChromeUserBanner +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [content](./kibana-plugin-core-public.chromeuserbanner.content.md) | MountPoint<HTMLDivElement> | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index f4bce8b51ebb1a..5be8f8ce7e8c7e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
executeWatchActionModes: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
putComponentTemplateMetadata: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
putComponentTemplateMetadata: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
} | | diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index e307b5c9971b0b..5524cf328fbfe6 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -56,6 +56,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeRecentlyAccessed](./kibana-plugin-core-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-core-public.chromerecentlyaccessed.md) for recently accessed history. | | [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-core-public.chromerecentlyaccessedhistoryitem.md) | | | [ChromeStart](./kibana-plugin-core-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | +| [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) | | | [CoreSetup](./kibana-plugin-core-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | | [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md index 0b7e6467667cb2..6fcfae559dd33f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md @@ -23,6 +23,7 @@ export interface UiSettingsParams | [name](./kibana-plugin-core-public.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-core-public.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | | [options](./kibana-plugin-core-public.uisettingsparams.options.md) | string[] | array of permitted values for this setting | +| [order](./kibana-plugin-core-public.uisettingsparams.order.md) | number | index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. settings without order defined will be displayed last and ordered by name | | [readonly](./kibana-plugin-core-public.uisettingsparams.readonly.md) | boolean | a flag indicating that value cannot be changed | | [requiresPageReload](./kibana-plugin-core-public.uisettingsparams.requirespagereload.md) | boolean | a flag indicating whether new value applying requires page reloading | | [schema](./kibana-plugin-core-public.uisettingsparams.schema.md) | Type<T> | | diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md new file mode 100644 index 00000000000000..d93aaeb9046168 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) > [order](./kibana-plugin-core-public.uisettingsparams.order.md) + +## UiSettingsParams.order property + +index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. + + settings without order defined will be displayed last and ordered by name + +Signature: + +```typescript +order?: number; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md b/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md index 5753704ccfe037..65e6264ea1e08a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md @@ -9,5 +9,5 @@ UI element type to represent the settings. Signature: ```typescript -export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md index d35afc4a149d19..4bb7be77c595a9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md @@ -23,6 +23,7 @@ export interface UiSettingsParams | [name](./kibana-plugin-core-server.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-core-server.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | | [options](./kibana-plugin-core-server.uisettingsparams.options.md) | string[] | array of permitted values for this setting | +| [order](./kibana-plugin-core-server.uisettingsparams.order.md) | number | index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. settings without order defined will be displayed last and ordered by name | | [readonly](./kibana-plugin-core-server.uisettingsparams.readonly.md) | boolean | a flag indicating that value cannot be changed | | [requiresPageReload](./kibana-plugin-core-server.uisettingsparams.requirespagereload.md) | boolean | a flag indicating whether new value applying requires page reloading | | [schema](./kibana-plugin-core-server.uisettingsparams.schema.md) | Type<T> | | diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md new file mode 100644 index 00000000000000..140bdad5d786bf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) > [order](./kibana-plugin-core-server.uisettingsparams.order.md) + +## UiSettingsParams.order property + +index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. + + settings without order defined will be displayed last and ordered by name + +Signature: + +```typescript +order?: number; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md b/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md index 3c439897ea0310..7edee442baa24b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md @@ -9,5 +9,5 @@ UI element type to represent the settings. Signature: ```typescript -export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; ``` diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index a1e40c06f6fa17..dacf0bc94b79d3 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -105,3 +105,4 @@ pageLoadAssetSize: spacesOss: 18817 osquery: 107090 fileUpload: 25664 + banners: 17946 diff --git a/src/core/public/_mixins.scss b/src/core/public/_mixins.scss new file mode 100644 index 00000000000000..2dbef465e074e6 --- /dev/null +++ b/src/core/public/_mixins.scss @@ -0,0 +1,43 @@ +@import './variables'; + +/* stylelint-disable-next-line length-zero-no-unit -- need consistent unit to sum them */ +@mixin kibanaFullBodyHeight($additionalOffset: 0px) { + // default - header, no banner + height: calc(100vh - #{$kbnHeaderOffset + $additionalOffset}); + + @at-root { + // no header, no banner + .kbnBody--chromeHidden & { + height: calc(100vh - #{$additionalOffset}); + } + // header, banner + .kbnBody--hasHeaderBanner & { + height: calc(100vh - #{$kbnHeaderOffsetWithBanner + $additionalOffset}); + } + // no header, banner + .kbnBody--chromeHidden.kbnBody--hasHeaderBanner & { + height: calc(100vh - #{$kbnHeaderBannerHeight + $additionalOffset}); + } + } +} + +/* stylelint-disable-next-line length-zero-no-unit -- need consistent unit to sum them */ +@mixin kibanaFullBodyMinHeight($additionalOffset: 0px) { + // default - header, no banner + min-height: calc(100vh - #{$kbnHeaderOffset + $additionalOffset}); + + @at-root { + // no header, no banner + .kbnBody--chromeHidden & { + min-height: calc(100vh - #{$additionalOffset}); + } + // header, banner + .kbnBody--hasHeaderBanner & { + min-height: calc(100vh - #{$kbnHeaderOffsetWithBanner + $additionalOffset}); + } + // no header, banner + .kbnBody--chromeHidden.kbnBody--hasHeaderBanner & { + min-height: calc(100vh - #{$kbnHeaderBannerHeight + $additionalOffset}); + } + } +} diff --git a/src/core/public/_variables.scss b/src/core/public/_variables.scss index 8c054e770bd4b7..f6ff5619bfc534 100644 --- a/src/core/public/_variables.scss +++ b/src/core/public/_variables.scss @@ -1,3 +1,8 @@ @import '@elastic/eui/src/global_styling/variables/header'; +// height of the header banner +$kbnHeaderBannerHeight: $euiSizeXL; +// total height of the header (when the banner is *not* present) $kbnHeaderOffset: $euiHeaderHeightCompensation * 2; +// total height of the header when the banner is present +$kbnHeaderOffsetWithBanner: $kbnHeaderOffset + $kbnHeaderBannerHeight; diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index cb0876f6bc7253..ae9c58af696032 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -61,6 +61,8 @@ const createStartContractMock = () => { getIsNavDrawerLocked$: jest.fn(), getCustomNavLink$: jest.fn(), setCustomNavLink: jest.fn(), + setHeaderBanner: jest.fn(), + getBodyClasses$: jest.fn(), }; startContract.navLinks.getAll.mockReturnValue([]); startContract.getBrand$.mockReturnValue(new BehaviorSubject({} as ChromeBrand)); @@ -72,6 +74,7 @@ const createStartContractMock = () => { startContract.getCustomNavLink$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getHelpExtension$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getIsNavDrawerLocked$.mockReturnValue(new BehaviorSubject(false)); + startContract.getBodyClasses$.mockReturnValue(new BehaviorSubject([])); return startContract; }; diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index ee8d1c17ccd59c..e69bf9025fdb92 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -6,69 +6,37 @@ * Side Public License, v 1. */ -import { EuiBreadcrumb, IconType } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; import { flatMap, map, takeUntil } from 'rxjs/operators'; import { parse } from 'url'; import { EuiLink } from '@elastic/eui'; -import { MountPoint } from '../types'; import { mountReactNode } from '../utils/mount'; import { InternalApplicationStart } from '../application'; import { DocLinksStart } from '../doc_links'; import { HttpStart } from '../http'; import { InjectedMetadataStart } from '../injected_metadata'; import { NotificationsStart } from '../notifications'; -import { IUiSettingsClient } from '../ui_settings'; import { KIBANA_ASK_ELASTIC_LINK } from './constants'; import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; -import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; +import { NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; -import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; export { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; +import { + ChromeBadge, + ChromeBrand, + ChromeBreadcrumb, + ChromeBreadcrumbsAppendExtension, + ChromeHelpExtension, + InternalChromeStart, + ChromeUserBanner, +} from './types'; const IS_LOCKED_KEY = 'core.chrome.isLocked'; -/** @public */ -export interface ChromeBadge { - text: string; - tooltip: string; - iconType?: IconType; -} - -/** @public */ -export interface ChromeBrand { - logo?: string; - smallLogo?: string; -} - -/** @public */ -export type ChromeBreadcrumb = EuiBreadcrumb; - -/** @public */ -export interface ChromeBreadcrumbsAppendExtension { - content: MountPoint; -} - -/** @public */ -export interface ChromeHelpExtension { - /** - * Provide your plugin's name to create a header for separation - */ - appName: string; - /** - * Creates unified links for sending users to documentation, GitHub, Discuss, or a custom link/button - */ - links?: ChromeHelpExtensionMenuLink[]; - /** - * Custom content to occur below the list of links - */ - content?: (element: HTMLDivElement) => () => void; -} - interface ConstructorParams { browserSupportsCsp: boolean; } @@ -79,7 +47,6 @@ interface StartDeps { http: HttpStart; injectedMetadata: InjectedMetadataStart; notifications: NotificationsStart; - uiSettings: IUiSettingsClient; } /** @internal */ @@ -132,7 +99,6 @@ export class ChromeService { http, injectedMetadata, notifications, - uiSettings, }: StartDeps): Promise { this.initVisibility(application); @@ -149,6 +115,17 @@ export class ChromeService { const helpSupportUrl$ = new BehaviorSubject(KIBANA_ASK_ELASTIC_LINK); const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true'); + const headerBanner$ = new BehaviorSubject(undefined); + const bodyClasses$ = combineLatest([headerBanner$, this.isVisible$!]).pipe( + map(([headerBanner, isVisible]) => { + return [ + 'kbnBody', + headerBanner ? 'kbnBody--hasHeaderBanner' : 'kbnBody--noHeaderBanner', + isVisible ? 'kbnBody--chromeVisible' : 'kbnBody--chromeHidden', + ]; + }) + ); + const navControls = this.navControls.start(); const navLinks = this.navLinks.start({ application, http }); const recentlyAccessed = await this.recentlyAccessed.start({ http }); @@ -220,6 +197,7 @@ export class ChromeService { loadingCount$={http.getLoadingCount$()} application={application} appTitle$={appTitle$.pipe(takeUntil(this.stop$))} + headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))} badge$={badge$.pipe(takeUntil(this.stop$))} basePath={http.basePath} breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))} @@ -312,6 +290,12 @@ export class ChromeService { setCustomNavLink: (customNavLink?: ChromeNavLink) => { customNavLink$.next(customNavLink); }, + + setHeaderBanner: (headerBanner?: ChromeUserBanner) => { + headerBanner$.next(headerBanner); + }, + + getBodyClasses$: () => bodyClasses$.pipe(takeUntil(this.stop$)), }; } @@ -320,173 +304,3 @@ export class ChromeService { this.stop$.next(); } } - -/** - * ChromeStart allows plugins to customize the global chrome header UI and - * enrich the UX with additional information about the current location of the - * browser. - * - * @remarks - * While ChromeStart exposes many APIs, they should be used sparingly and the - * developer should understand how they affect other plugins and applications. - * - * @example - * How to add a recently accessed item to the sidebar: - * ```ts - * core.chrome.recentlyAccessed.add('/app/map/1234', 'Map 1234', '1234'); - * ``` - * - * @example - * How to set the help dropdown extension: - * ```tsx - * core.chrome.setHelpExtension(elem => { - * ReactDOM.render(, elem); - * return () => ReactDOM.unmountComponentAtNode(elem); - * }); - * ``` - * - * @public - */ -export interface ChromeStart { - /** {@inheritdoc ChromeNavLinks} */ - navLinks: ChromeNavLinks; - /** {@inheritdoc ChromeNavControls} */ - navControls: ChromeNavControls; - /** {@inheritdoc ChromeRecentlyAccessed} */ - recentlyAccessed: ChromeRecentlyAccessed; - /** {@inheritdoc ChromeDocTitle} */ - docTitle: ChromeDocTitle; - - /** - * Sets the current app's title - * - * @internalRemarks - * This should be handled by the application service once it is in charge - * of mounting applications. - */ - setAppTitle(appTitle: string): void; - - /** - * Get an observable of the current brand information. - */ - getBrand$(): Observable; - - /** - * Set the brand configuration. - * - * @remarks - * Normally the `logo` property will be rendered as the - * CSS background for the home link in the chrome navigation, but when the page is - * rendered in a small window the `smallLogo` will be used and rendered at about - * 45px wide. - * - * @example - * ```js - * chrome.setBrand({ - * logo: 'url(/plugins/app/logo.png) center no-repeat' - * smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat' - * }) - * ``` - * - */ - setBrand(brand: ChromeBrand): void; - - /** - * Get an observable of the current visibility state of the chrome. - */ - getIsVisible$(): Observable; - - /** - * Set the temporary visibility for the chrome. This does nothing if the chrome is hidden - * by default and should be used to hide the chrome for things like full-screen modes - * with an exit button. - */ - setIsVisible(isVisible: boolean): void; - - /** - * Get the current set of classNames that will be set on the application container. - */ - getApplicationClasses$(): Observable; - - /** - * Add a className that should be set on the application container. - */ - addApplicationClass(className: string): void; - - /** - * Remove a className added with `addApplicationClass()`. If className is unknown it is ignored. - */ - removeApplicationClass(className: string): void; - - /** - * Get an observable of the current badge - */ - getBadge$(): Observable; - - /** - * Override the current badge - */ - setBadge(badge?: ChromeBadge): void; - - /** - * Get an observable of the current list of breadcrumbs - */ - getBreadcrumbs$(): Observable; - - /** - * Override the current set of breadcrumbs - */ - setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; - - /** - * Get an observable of the current extension appended to breadcrumbs - */ - getBreadcrumbsAppendExtension$(): Observable; - - /** - * Mount an element next to the last breadcrumb - */ - setBreadcrumbsAppendExtension( - breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension - ): void; - - /** - * Get an observable of the current custom nav link - */ - getCustomNavLink$(): Observable | undefined>; - - /** - * Override the current set of custom nav link - */ - setCustomNavLink(newCustomNavLink?: Partial): void; - - /** - * Get an observable of the current custom help conttent - */ - getHelpExtension$(): Observable; - - /** - * Override the current set of custom help content - */ - setHelpExtension(helpExtension?: ChromeHelpExtension): void; - - /** - * Override the default support URL shown in the help menu - * @param url The updated support URL - */ - setHelpSupportUrl(url: string): void; - - /** - * Get an observable of the current locked state of the nav drawer. - */ - getIsNavDrawerLocked$(): Observable; -} - -/** @internal */ -export interface InternalChromeStart extends ChromeStart { - /** - * Used only by MountingService to render the header UI - * @internal - */ - getHeaderComponent(): JSX.Element; -} diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index cf4106a42e0d4d..069d29ca70d01e 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -6,15 +6,7 @@ * Side Public License, v 1. */ -export { - ChromeBadge, - ChromeBreadcrumb, - ChromeService, - ChromeStart, - InternalChromeStart, - ChromeBrand, - ChromeHelpExtension, -} from './chrome_service'; +export { ChromeService } from './chrome_service'; export { ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuLink, @@ -28,3 +20,13 @@ export { ChromeNavLink, ChromeNavLinks, ChromeNavLinkUpdateableFields } from './ export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem } from './recently_accessed'; export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; +export { + InternalChromeStart, + ChromeStart, + ChromeHelpExtension, + ChromeBreadcrumbsAppendExtension, + ChromeBreadcrumb, + ChromeBrand, + ChromeBadge, + ChromeUserBanner, +} from './types'; diff --git a/src/core/public/chrome/types.ts b/src/core/public/chrome/types.ts new file mode 100644 index 00000000000000..732236f1ba4a12 --- /dev/null +++ b/src/core/public/chrome/types.ts @@ -0,0 +1,241 @@ +/* + * Copyright 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 { EuiBreadcrumb, IconType } from '@elastic/eui'; +import { Observable } from 'rxjs'; +import { MountPoint } from '../types'; +import { ChromeDocTitle } from './doc_title'; +import { ChromeNavControls } from './nav_controls'; +import { ChromeNavLinks, ChromeNavLink } from './nav_links'; +import { ChromeRecentlyAccessed } from './recently_accessed'; +import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; + +/** @public */ +export interface ChromeBadge { + text: string; + tooltip: string; + iconType?: IconType; +} + +/** @public */ +export interface ChromeBrand { + logo?: string; + smallLogo?: string; +} + +/** @public */ +export type ChromeBreadcrumb = EuiBreadcrumb; + +/** @public */ +export interface ChromeBreadcrumbsAppendExtension { + content: MountPoint; +} + +/** @public */ +export interface ChromeUserBanner { + content: MountPoint; +} + +/** @public */ +export interface ChromeHelpExtension { + /** + * Provide your plugin's name to create a header for separation + */ + appName: string; + /** + * Creates unified links for sending users to documentation, GitHub, Discuss, or a custom link/button + */ + links?: ChromeHelpExtensionMenuLink[]; + /** + * Custom content to occur below the list of links + */ + content?: (element: HTMLDivElement) => () => void; +} + +/** + * ChromeStart allows plugins to customize the global chrome header UI and + * enrich the UX with additional information about the current location of the + * browser. + * + * @remarks + * While ChromeStart exposes many APIs, they should be used sparingly and the + * developer should understand how they affect other plugins and applications. + * + * @example + * How to add a recently accessed item to the sidebar: + * ```ts + * core.chrome.recentlyAccessed.add('/app/map/1234', 'Map 1234', '1234'); + * ``` + * + * @example + * How to set the help dropdown extension: + * ```tsx + * core.chrome.setHelpExtension(elem => { + * ReactDOM.render(, elem); + * return () => ReactDOM.unmountComponentAtNode(elem); + * }); + * ``` + * + * @public + */ +export interface ChromeStart { + /** {@inheritdoc ChromeNavLinks} */ + navLinks: ChromeNavLinks; + /** {@inheritdoc ChromeNavControls} */ + navControls: ChromeNavControls; + /** {@inheritdoc ChromeRecentlyAccessed} */ + recentlyAccessed: ChromeRecentlyAccessed; + /** {@inheritdoc ChromeDocTitle} */ + docTitle: ChromeDocTitle; + + /** + * Sets the current app's title + * + * @internalRemarks + * This should be handled by the application service once it is in charge + * of mounting applications. + */ + setAppTitle(appTitle: string): void; + + /** + * Get an observable of the current brand information. + */ + getBrand$(): Observable; + + /** + * Set the brand configuration. + * + * @remarks + * Normally the `logo` property will be rendered as the + * CSS background for the home link in the chrome navigation, but when the page is + * rendered in a small window the `smallLogo` will be used and rendered at about + * 45px wide. + * + * @example + * ```js + * chrome.setBrand({ + * logo: 'url(/plugins/app/logo.png) center no-repeat' + * smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat' + * }) + * ``` + * + */ + setBrand(brand: ChromeBrand): void; + + /** + * Get an observable of the current visibility state of the chrome. + */ + getIsVisible$(): Observable; + + /** + * Set the temporary visibility for the chrome. This does nothing if the chrome is hidden + * by default and should be used to hide the chrome for things like full-screen modes + * with an exit button. + */ + setIsVisible(isVisible: boolean): void; + + /** + * Get the current set of classNames that will be set on the application container. + */ + getApplicationClasses$(): Observable; + + /** + * Add a className that should be set on the application container. + */ + addApplicationClass(className: string): void; + + /** + * Remove a className added with `addApplicationClass()`. If className is unknown it is ignored. + */ + removeApplicationClass(className: string): void; + + /** + * Get an observable of the current badge + */ + getBadge$(): Observable; + + /** + * Override the current badge + */ + setBadge(badge?: ChromeBadge): void; + + /** + * Get an observable of the current list of breadcrumbs + */ + getBreadcrumbs$(): Observable; + + /** + * Override the current set of breadcrumbs + */ + setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; + + /** + * Get an observable of the current extension appended to breadcrumbs + */ + getBreadcrumbsAppendExtension$(): Observable; + + /** + * Mount an element next to the last breadcrumb + */ + setBreadcrumbsAppendExtension( + breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension + ): void; + + /** + * Get an observable of the current custom nav link + */ + getCustomNavLink$(): Observable | undefined>; + + /** + * Override the current set of custom nav link + */ + setCustomNavLink(newCustomNavLink?: Partial): void; + + /** + * Get an observable of the current custom help conttent + */ + getHelpExtension$(): Observable; + + /** + * Override the current set of custom help content + */ + setHelpExtension(helpExtension?: ChromeHelpExtension): void; + + /** + * Override the default support URL shown in the help menu + * @param url The updated support URL + */ + setHelpSupportUrl(url: string): void; + + /** + * Get an observable of the current locked state of the nav drawer. + */ + getIsNavDrawerLocked$(): Observable; + + /** + * Set the banner that will appear on top of the chrome header. + * + * @remarks Using `undefined` when invoking this API will remove the banner. + */ + setHeaderBanner(headerBanner?: ChromeUserBanner): void; +} + +/** @internal */ +export interface InternalChromeStart extends ChromeStart { + /** + * Used only by the rendering service to render the header UI + * @internal + */ + getHeaderComponent(): JSX.Element; + /** + * Used only by the rendering service to retrieve the set of classNames + * that will be set on the body element. + * @internal + */ + getBodyClasses$(): Observable; +} diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 9e7906250949e4..4f31c952b88268 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -452,6 +452,55 @@ exports[`Header renders 1`] = ` "thrownError": null, } } + headerBanner$={ + BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } helpExtension$={ BehaviorSubject { "_isScalar": false, @@ -1699,14 +1748,67 @@ exports[`Header renders 1`] = ` } } > +
({ htmlIdGenerator: () => () => 'mockId', @@ -63,6 +63,7 @@ describe('Header', () => { const navLinks$ = new BehaviorSubject([ { id: 'kibana', title: 'kibana', baseUrl: '', href: '' }, ]); + const headerBanner$ = new BehaviorSubject(undefined); const customNavLink$ = new BehaviorSubject({ id: 'cloud-deployment-link', title: 'Manage cloud deployment', @@ -85,6 +86,7 @@ describe('Header', () => { isLocked$={isLocked$} customNavLink$={customNavLink$} breadcrumbsAppendExtension$={breadcrumbsAppendExtension$} + headerBanner$={headerBanner$} /> ); expect(component.find('EuiHeader').exists()).toBeFalsy(); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index b55e7fc412b61a..16c89fdca380ab 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -32,7 +32,11 @@ import { } from '../..'; import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; -import { ChromeBreadcrumbsAppendExtension, ChromeHelpExtension } from '../../chrome_service'; +import { + ChromeBreadcrumbsAppendExtension, + ChromeHelpExtension, + ChromeUserBanner, +} from '../../types'; import { OnIsLockedUpdate } from './'; import { CollapsibleNav } from './collapsible_nav'; import { HeaderBadge } from './header_badge'; @@ -42,10 +46,12 @@ import { HeaderLogo } from './header_logo'; import { HeaderNavControls } from './header_nav_controls'; import { HeaderActionMenu } from './header_action_menu'; import { HeaderExtension } from './header_extension'; +import { HeaderTopBanner } from './header_top_banner'; export interface HeaderProps { kibanaVersion: string; application: InternalApplicationStart; + headerBanner$: Observable; appTitle$: Observable; badge$: Observable; breadcrumbs$: Observable; @@ -84,7 +90,12 @@ export function Header({ const breadcrumbsAppendExtension = useObservable(breadcrumbsAppendExtension$); if (!isVisible) { - return ; + return ( + <> + + + + ); } const toggleCollapsibleNavRef = createRef(); @@ -97,11 +108,13 @@ export function Header({ return ( <> +
-
+
- + ; diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index 4db79d0233ae9a..0e2bae82a3ad30 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -11,7 +11,7 @@ import classNames from 'classnames'; import React from 'react'; import useObservable from 'react-use/lib/useObservable'; import { Observable } from 'rxjs'; -import { ChromeBreadcrumb } from '../../chrome_service'; +import { ChromeBreadcrumb } from '../../types'; interface Props { appTitle$: Observable; diff --git a/src/core/public/chrome/ui/header/header_help_menu.tsx b/src/core/public/chrome/ui/header/header_help_menu.tsx index a20613f7e77efb..c6a09c1177a5e8 100644 --- a/src/core/public/chrome/ui/header/header_help_menu.tsx +++ b/src/core/public/chrome/ui/header/header_help_menu.tsx @@ -26,7 +26,7 @@ import { import { InternalApplicationStart } from '../../../application'; import { GITHUB_CREATE_ISSUE_LINK, KIBANA_FEEDBACK_LINK } from '../../constants'; -import { ChromeHelpExtension } from '../../chrome_service'; +import { ChromeHelpExtension } from '../../types'; import { HeaderExtension } from './header_extension'; import { isModifiedOrPrevented } from './nav_link'; diff --git a/src/core/public/chrome/ui/header/header_top_banner.tsx b/src/core/public/chrome/ui/header/header_top_banner.tsx new file mode 100644 index 00000000000000..667cf9025880f3 --- /dev/null +++ b/src/core/public/chrome/ui/header/header_top_banner.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 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 useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; +import { ChromeUserBanner } from '../../types'; +import { HeaderExtension } from './header_extension'; + +export interface HeaderTopBannerProps { + headerBanner$: Observable; +} + +export const HeaderTopBanner: FC = ({ headerBanner$ }) => { + const headerBanner = useObservable(headerBanner$, undefined); + if (!headerBanner) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/src/core/public/core_app/styles/_mixins.scss b/src/core/public/core_app/styles/_mixins.scss index 6da7fab8c2f76d..d088a47144f33a 100644 --- a/src/core/public/core_app/styles/_mixins.scss +++ b/src/core/public/core_app/styles/_mixins.scss @@ -1,3 +1,5 @@ +@import '../../variables'; + @mixin flexParent($grow: 1, $shrink: 1, $basis: auto, $direction: column) { flex: $grow $shrink $basis; display: flex; @@ -82,6 +84,12 @@ overflow: auto; animation: kibanaFullScreenGraphics_FadeIn $euiAnimSpeedExtraSlow $euiAnimSlightResistance 0s forwards; + @at-root { + .kbnBody--hasHeaderBanner & { + top: $kbnHeaderBannerHeight; + } + } + &::before { position: absolute; top: 0; diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 02e12ddf4b78bf..278bbe469e862d 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -194,7 +194,6 @@ export class CoreSystem { http, injectedMetadata, notifications, - uiSettings, }); this.coreApp.start({ application, http, notifications, uiSettings }); diff --git a/src/core/public/index.scss b/src/core/public/index.scss index 6ba9254e5d381f..04e2759c91d5dc 100644 --- a/src/core/public/index.scss +++ b/src/core/public/index.scss @@ -1,4 +1,5 @@ @import './variables'; +@import './mixins'; @import './core'; @import './chrome/index'; @import './overlays/index'; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index a1cb036ce38f8f..67a7f15fd92411 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -46,6 +46,7 @@ import { ChromeStart, ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, + ChromeUserBanner, NavType, } from './chrome'; import { FatalErrorsSetup, FatalErrorsStart, FatalErrorInfo } from './fatal_errors'; @@ -299,6 +300,7 @@ export { ChromeDocTitle, ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, + ChromeUserBanner, ChromeStart, DocLinksStart, FatalErrorInfo, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 99579ada8ec588..b4a2c40f3003b6 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -378,11 +378,18 @@ export interface ChromeStart { setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; setBreadcrumbsAppendExtension(breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension): void; setCustomNavLink(newCustomNavLink?: Partial): void; + setHeaderBanner(headerBanner?: ChromeUserBanner): void; setHelpExtension(helpExtension?: ChromeHelpExtension): void; setHelpSupportUrl(url: string): void; setIsVisible(isVisible: boolean): void; } +// @public (undocumented) +export interface ChromeUserBanner { + // (undocumented) + content: MountPoint; +} + // @internal (undocumented) export interface CoreContext { // Warning: (ae-forgotten-export) The symbol "CoreId" needs to be exported by the entry point index.d.ts @@ -1519,6 +1526,7 @@ export interface UiSettingsParams { name?: string; optionLabels?: Record; options?: string[]; + order?: number; readonly?: boolean; requiresPageReload?: boolean; // (undocumented) @@ -1537,7 +1545,7 @@ export interface UiSettingsState { } // @public -export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; // @public export type UnmountCallback = () => void; diff --git a/src/core/public/rendering/_base.scss b/src/core/public/rendering/_base.scss index b806ac270331d2..de13785a17f5b9 100644 --- a/src/core/public/rendering/_base.scss +++ b/src/core/public/rendering/_base.scss @@ -1,4 +1,4 @@ -@include euiHeaderAffordForFixed($kbnHeaderOffset); +@import '../mixins'; /** * stretch the root element of the Kibana application to set the base-size that @@ -15,11 +15,8 @@ display: flex; flex-flow: column nowrap; margin: 0 auto; - min-height: calc(100vh - #{$kbnHeaderOffset}); - &.hidden-chrome { - min-height: 100vh; - } + @include kibanaFullBodyMinHeight(); } .app-wrapper-panel { @@ -33,3 +30,28 @@ flex-shrink: 0; } } + +// adapted from euiHeaderAffordForFixed as we need to handle the top banner +@mixin kbnAffordForHeader($headerHeight) { + padding-top: $headerHeight; + + .euiFlyout, + .euiCollapsibleNav { + top: $headerHeight; + height: calc(100% - #{$headerHeight}); + } +} + +.kbnBody { + @include kbnAffordForHeader($kbnHeaderOffset); + + &.kbnBody--hasHeaderBanner { + @include kbnAffordForHeader($kbnHeaderOffsetWithBanner); + } + &.kbnBody--chromeHidden { + @include kbnAffordForHeader(0); + } + &.kbnBody--chromeHidden.kbnBody--hasHeaderBanner { + @include kbnAffordForHeader($kbnHeaderBannerHeight); + } +} diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 47b340eed8468b..843f2a253f33ec 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -9,6 +9,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; +import { pairwise, startWith } from 'rxjs/operators'; import { InternalChromeStart } from '../chrome'; import { InternalApplicationStart } from '../application'; @@ -32,19 +33,27 @@ interface StartDeps { */ export class RenderingService { start({ application, chrome, overlays, targetDomElement }: StartDeps) { - const chromeUi = chrome.getHeaderComponent(); - const appUi = application.getComponent(); - const bannerUi = overlays.banners.getComponent(); + const chromeHeader = chrome.getHeaderComponent(); + const appComponent = application.getComponent(); + const bannerComponent = overlays.banners.getComponent(); + + const body = document.querySelector('body')!; + chrome + .getBodyClasses$() + .pipe(startWith([]), pairwise()) + .subscribe(([previousClasses, newClasses]) => { + body.classList.remove(...previousClasses); + body.classList.add(...newClasses); + }); ReactDOM.render(
- {chromeUi} - + {chromeHeader}
-
{bannerUi}
- {appUi} +
{bannerComponent}
+ {appComponent}
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 09207608908a45..2b36ab58896b16 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -3112,6 +3112,7 @@ export interface UiSettingsParams { name?: string; optionLabels?: Record; options?: string[]; + order?: number; readonly?: boolean; requiresPageReload?: boolean; // (undocumented) @@ -3134,7 +3135,7 @@ export interface UiSettingsServiceStart { } // @public -export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; // @public export interface UserProvidedValues { diff --git a/src/core/types/ui_settings.ts b/src/core/types/ui_settings.ts index e50dc18d9ff1fe..235553293d1537 100644 --- a/src/core/types/ui_settings.ts +++ b/src/core/types/ui_settings.ts @@ -22,7 +22,8 @@ export type UiSettingsType = | 'boolean' | 'string' | 'array' - | 'image'; + | 'image' + | 'color'; /** * UiSettings deprecation field options. @@ -65,6 +66,13 @@ export interface UiSettingsParams { type?: UiSettingsType; /** optional deprecation information. Used to generate a deprecation warning. */ deprecation?: DeprecationSettings; + /** + * index of the settings within its category (ascending order, smallest will be displayed first). + * Used for ordering in the UI. + * + * @remark settings without order defined will be displayed last and ordered by name + */ + order?: number; /* * Allows defining a custom validation applicable to value change on the client. * @deprecated diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index 0be582a4c0294d..c7a8c0a6135c7a 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -12,7 +12,7 @@ import { UnregisterCallback } from 'history'; import { parse } from 'query-string'; import { UiCounterMetricType } from '@kbn/analytics'; -import { Comparators, EuiFlexGroup, EuiFlexItem, EuiSpacer, Query } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, Query } from '@elastic/eui'; import { IUiSettingsClient, @@ -28,7 +28,7 @@ import { Form } from './components/form'; import { AdvancedSettingsVoiceAnnouncement } from './components/advanced_settings_voice_announcement'; import { ComponentRegistry } from '../'; -import { getAriaName, toEditableConfig, DEFAULT_CATEGORY } from './lib'; +import { getAriaName, toEditableConfig, fieldSorter, DEFAULT_CATEGORY } from './lib'; import { FieldSetting, SettingsChanges } from './types'; import { parseErrorMsg } from './components/search/search'; @@ -185,17 +185,17 @@ export class AdvancedSettings extends Component { + .map(([settingId, settingDef]) => { return toEditableConfig({ - def: setting[1], - name: setting[0], - value: setting[1].userValue, - isCustom: config.isCustom(setting[0]), - isOverridden: config.isOverridden(setting[0]), + def: settingDef, + name: settingId, + value: settingDef.userValue, + isCustom: config.isCustom(settingId), + isOverridden: config.isOverridden(settingId), }); }) - .filter((c) => !c.readonly) - .sort(Comparators.property('name', Comparators.default('asc'))); + .filter((c) => !c.readOnly) + .sort(fieldSorter); } mapSettings(settings: FieldSetting[]) { diff --git a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap index 19bf9e6d73757b..517a6238c25191 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap +++ b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap @@ -866,6 +866,419 @@ exports[`Field for boolean setting should render user value if there is user val `; +exports[`Field for color setting should render as read only if saving is disabled 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + +`; + +exports[`Field for color setting should render as read only with help text if overridden 1`] = ` + +
+ + + + + + null + , + } + } + /> + + + + + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + } + label="color:test:setting" + labelType="label" + > + + + +`; + +exports[`Field for color setting should render custom setting icon if it is custom 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + } + type="asterisk" + /> + +

+ } +> + + + + +`; + +exports[`Field for color setting should render default value if there is no user value set 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + +`; + +exports[`Field for color setting should render unsaved value if there are unsaved changes 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + } + type="asterisk" + /> + +

+ } +> + + + +

+ Setting is currently not saved. +

+
+
+ +`; + +exports[`Field for color setting should render user value if there is user value is set 1`] = ` + +
+ + + + + + null + , + } + } + /> + + + + + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + + +     + + + } + label="color:test:setting" + labelType="label" + > + + + +`; + exports[`Field for image setting should render as read only if saving is disabled 1`] = ` = { isOverridden: false, ...defaults, }, + color: { + name: 'color:test:setting', + ariaName: 'color test setting', + displayName: 'Color test setting', + description: 'Description for Color test setting', + type: 'color', + value: undefined, + defVal: null, + isCustom: false, + isOverridden: false, + ...defaults, + }, }; const userValues = { array: ['user', 'value'], @@ -174,6 +187,7 @@ const userValues = { select: 'banana', string: 'foo', stringWithValidation: 'fooUserValue', + color: '#FACF0C', }; const invalidUserValues = { @@ -187,6 +201,8 @@ const getFieldSettingValue = (wrapper: ReactWrapper, name: string, type: string) const field = findTestSubject(wrapper, `advancedSetting-editField-${name}`); if (type === 'boolean') { return field.props()['aria-checked']; + } else if (type === 'color') { + return field.props().color; } else { return field.props().value; } @@ -423,6 +439,36 @@ describe('Field', () => { }); } }); + } else if (type === 'color') { + describe(`for changing ${type} setting`, () => { + const { wrapper, component } = setup(); + const userValue = userValues[type]; + + it('should be able to change value', async () => { + await (component.instance() as Field).onFieldChange(userValue); + const updated = wrapper.update(); + expect(handleChange).toBeCalledWith(setting.name, { value: userValue }); + updated.setProps({ unsavedChanges: { value: userValue } }); + const currentValue = wrapper.find('EuiColorPicker').prop('color'); + expect(currentValue).toEqual(userValue); + }); + + it('should be able to reset to default value', async () => { + await wrapper.setProps({ + unsavedChanges: {}, + setting: { ...setting, value: userValue }, + }); + const updated = wrapper.update(); + findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click'); + const expectedEditableValue = getEditableValue(setting.type, setting.defVal); + expect(handleChange).toBeCalledWith(setting.name, { + value: expectedEditableValue, + }); + updated.setProps({ unsavedChanges: { value: expectedEditableValue } }); + const currentValue = wrapper.find('EuiColorPicker').prop('color'); + expect(currentValue).toEqual(expectedEditableValue); + }); + }); } else { describe(`for changing ${type} setting`, () => { const { wrapper, component } = setup(); diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index 5569a6e11872a0..f5db5c3e371b39 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -17,6 +17,7 @@ import { EuiBadge, EuiCode, EuiCodeBlock, + EuiColorPicker, EuiScreenReaderOnly, EuiCodeEditor, EuiDescribedFormGroup, @@ -392,6 +393,17 @@ export class Field extends PureComponent { data-test-subj={`advancedSetting-editField-${name}`} /> ); + case 'color': + return ( + + ); default: return ( ): FieldSetting => ({ + displayName: 'displayName', + name: 'field', + value: 'value', + requiresPageReload: false, + type: 'string', + category: [], + ariaName: 'ariaName', + isOverridden: false, + defVal: 'defVal', + isCustom: false, + ...parts, +}); + +describe('fieldSorter', () => { + it('sort fields based on their `order` field if present on both', () => { + const fieldA = createField({ order: 3 }); + const fieldB = createField({ order: 1 }); + const fieldC = createField({ order: 2 }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldB, fieldC, fieldA]); + }); + it('fields with order defined are ordered first', () => { + const fieldA = createField({ order: 2 }); + const fieldB = createField({ order: undefined }); + const fieldC = createField({ order: 1 }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldC, fieldA, fieldB]); + }); + it('sorts by `name` when fields have the same `order`', () => { + const fieldA = createField({ order: 2, name: 'B' }); + const fieldB = createField({ order: 1 }); + const fieldC = createField({ order: 2, name: 'A' }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldB, fieldC, fieldA]); + }); + + it('sorts by `name` when fields have no `order`', () => { + const fieldA = createField({ order: undefined, name: 'B' }); + const fieldB = createField({ order: undefined, name: 'A' }); + const fieldC = createField({ order: 1 }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldC, fieldB, fieldA]); + }); +}); diff --git a/src/plugins/advanced_settings/public/management_app/lib/sort_fields.ts b/src/plugins/advanced_settings/public/management_app/lib/sort_fields.ts new file mode 100644 index 00000000000000..90bfa18d2198e6 --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/lib/sort_fields.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 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 { Comparators } from '@elastic/eui'; +import { FieldSetting } from '../types'; + +const cmp = Comparators.default('asc'); + +export const fieldSorter = (a: FieldSetting, b: FieldSetting): number => { + const aOrder = a.order !== undefined; + const bOrder = b.order !== undefined; + + if (aOrder && bOrder) { + if (a.order === b.order) { + return cmp(a.name, b.name); + } + return cmp(a.order, b.order); + } + if (aOrder) { + return -1; + } + if (bOrder) { + return 1; + } + return cmp(a.name, b.name); +}; diff --git a/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts b/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts index b2b7f1c1016cdb..49abe3b279a28c 100644 --- a/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts +++ b/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts @@ -12,6 +12,7 @@ import { StringValidationRegexString, SavedObjectAttribute, } from 'src/core/public'; +import { FieldSetting } from '../types'; import { getValType } from './get_val_type'; import { getAriaName } from './get_aria_name'; import { DEFAULT_CATEGORY } from './default_category'; @@ -41,7 +42,7 @@ export function toEditableConfig({ const validationTyped = def.validation as StringValidationRegexString; - const conf = { + const conf: FieldSetting = { name, displayName: def.name || name, ariaName: def.name || getAriaName(name), @@ -49,7 +50,7 @@ export function toEditableConfig({ category: def.category && def.category.length ? def.category : [DEFAULT_CATEGORY], isCustom, isOverridden, - readonly: !!def.readonly, + readOnly: !!def.readonly, defVal: def.value, type: getValType(def, value), description: def.description, @@ -63,6 +64,7 @@ export function toEditableConfig({ : def.validation, options: def.options, optionLabels: def.optionLabels, + order: def.order, requiresPageReload: !!def.requiresPageReload, metric: def.metric, }; diff --git a/src/plugins/advanced_settings/public/management_app/types.ts b/src/plugins/advanced_settings/public/management_app/types.ts index 0563fa310bc77a..50b39114d2143e 100644 --- a/src/plugins/advanced_settings/public/management_app/types.ts +++ b/src/plugins/advanced_settings/public/management_app/types.ts @@ -25,6 +25,7 @@ export interface FieldSetting { isCustom: boolean; validation?: StringValidation | ImageValidation; readOnly?: boolean; + order?: number; deprecation?: { message: string; docLinksKey: string; diff --git a/src/plugins/discover/public/application/components/discover.scss b/src/plugins/discover/public/application/components/discover.scss index 90bfd84c4d54e5..02e60700d49d81 100644 --- a/src/plugins/discover/public/application/components/discover.scss +++ b/src/plugins/discover/public/application/components/discover.scss @@ -1,10 +1,12 @@ +@import '../../../../../core/public/mixins'; + discover-app { flex-grow: 1; } .dscPage { @include euiBreakpoint('m', 'l', 'xl') { - height: calc(100vh - #{($euiHeaderHeightCompensation * 2)}); + @include kibanaFullBodyHeight(); } flex-direction: column; diff --git a/src/plugins/home/public/application/components/_home.scss b/src/plugins/home/public/application/components/_home.scss index 5ff0d0f21b9858..913e1511a63147 100644 --- a/src/plugins/home/public/application/components/_home.scss +++ b/src/plugins/home/public/application/components/_home.scss @@ -1,8 +1,10 @@ +@import '../../../../../core/public/mixins'; + .homWrapper { + @include kibanaFullBodyMinHeight(); background-color: $euiColorEmptyShade; display: flex; flex-direction: column; - min-height: calc(100vh - #{$euiHeaderHeightCompensation}); } .homContent { diff --git a/src/plugins/home/public/application/components/home.js b/src/plugins/home/public/application/components/home.js index cec815a1a9bc69..3c1ba8eea22ca4 100644 --- a/src/plugins/home/public/application/components/home.js +++ b/src/plugins/home/public/application/components/home.js @@ -51,6 +51,9 @@ export class Home extends Component { componentWillUnmount() { this._isMounted = false; + + const body = document.querySelector('body'); + body.classList.remove('isHomPage'); } componentDidMount() { diff --git a/src/plugins/kibana_overview/public/components/_overview.scss b/src/plugins/kibana_overview/public/components/_overview.scss index 5b750202310fb8..94555013d0a772 100644 --- a/src/plugins/kibana_overview/public/components/_overview.scss +++ b/src/plugins/kibana_overview/public/components/_overview.scss @@ -1,8 +1,10 @@ +@import '../../../../core/public/mixins'; + .kbnOverviewWrapper { + @include kibanaFullBodyMinHeight(); background-color: $euiColorEmptyShade; display: flex; flex-direction: column; - min-height: calc(100vh - #{$euiHeaderHeightCompensation}); } .kbnOverviewContent { diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index c09198b3874a18..9c779ddab5cf53 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -58,7 +58,8 @@ "xpack.uptime": ["plugins/uptime"], "xpack.urlDrilldown": "plugins/drilldowns/url_drilldown", "xpack.watcher": "plugins/watcher", - "xpack.observability": "plugins/observability" + "xpack.observability": "plugins/observability", + "xpack.banners": "plugins/banners" }, "exclude": ["examples"], "translations": [ diff --git a/x-pack/plugins/banners/README.md b/x-pack/plugins/banners/README.md new file mode 100644 index 00000000000000..890c194e1bcb0c --- /dev/null +++ b/x-pack/plugins/banners/README.md @@ -0,0 +1,38 @@ +# Kibana banners plugin + +Allow to add a header banner that will be displayed on every page of the Kibana application + +## Configuration + +The plugin's configuration prefix is `xpack.banners` + +The options are + +- `placement` + +The placement of the banner. The allowed values are: + - `disabled` - The banner will be disabled + - `header` - The banner will be displayed in the header + +- `textContent` + +The text content that will be displayed inside the banner, either plain text or markdown + +- `textColor` + +The color of the banner's text. Must be a valid hex color + +- `backgroundColor` + +The color for the banner's background. Must be a valid hex color + +### Configuration example + +`kibana.yml` +```yaml +xpack.banners: + placement: 'header' + textContent: 'Production environment - Proceed with **special levels** of caution' + textColor: '#FF0000' + backgroundColor: '#CC2211' +``` \ No newline at end of file diff --git a/x-pack/plugins/banners/common/index.ts b/x-pack/plugins/banners/common/index.ts new file mode 100644 index 00000000000000..a4c38a58ab5726 --- /dev/null +++ b/x-pack/plugins/banners/common/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 { BannerInfoResponse, BannerPlacement, BannerConfiguration } from './types'; diff --git a/x-pack/plugins/banners/common/types.ts b/x-pack/plugins/banners/common/types.ts new file mode 100644 index 00000000000000..0c785f516ddb3b --- /dev/null +++ b/x-pack/plugins/banners/common/types.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. + */ + +export interface BannerInfoResponse { + allowed: boolean; + banner: BannerConfiguration; +} + +export type BannerPlacement = 'disabled' | 'header'; + +export interface BannerConfiguration { + placement: BannerPlacement; + textContent: string; + textColor: string; + backgroundColor: string; +} diff --git a/x-pack/plugins/banners/jest.config.js b/x-pack/plugins/banners/jest.config.js new file mode 100644 index 00000000000000..e2d103c8e4a284 --- /dev/null +++ b/x-pack/plugins/banners/jest.config.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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/banners'], +}; diff --git a/x-pack/plugins/banners/kibana.json b/x-pack/plugins/banners/kibana.json new file mode 100644 index 00000000000000..3e9441aaa27269 --- /dev/null +++ b/x-pack/plugins/banners/kibana.json @@ -0,0 +1,11 @@ +{ + "id": "banners", + "version": "8.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["licensing"], + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"], + "configPath": ["xpack", "banners"] +} diff --git a/x-pack/plugins/banners/public/components/banner.scss b/x-pack/plugins/banners/public/components/banner.scss new file mode 100644 index 00000000000000..586605becb45a5 --- /dev/null +++ b/x-pack/plugins/banners/public/components/banner.scss @@ -0,0 +1,7 @@ +.kbnUserBanner__container { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: $euiFontSizeS; +} diff --git a/x-pack/plugins/banners/public/components/banner.tsx b/x-pack/plugins/banners/public/components/banner.tsx new file mode 100644 index 00000000000000..ea30e46881d0cd --- /dev/null +++ b/x-pack/plugins/banners/public/components/banner.tsx @@ -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 React, { FC } from 'react'; +import { Markdown } from '../../../../../src/plugins/kibana_react/public'; +import { BannerConfiguration } from '../../common'; + +import './banner.scss'; + +interface BannerProps { + bannerConfig: BannerConfiguration; +} + +export const Banner: FC = ({ bannerConfig }) => { + const { textContent, textColor, backgroundColor } = bannerConfig; + return ( +
+
+ +
+
+ ); +}; diff --git a/x-pack/plugins/banners/public/components/index.ts b/x-pack/plugins/banners/public/components/index.ts new file mode 100644 index 00000000000000..c23c24fd9c1639 --- /dev/null +++ b/x-pack/plugins/banners/public/components/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 { Banner } from './banner'; diff --git a/x-pack/plugins/banners/public/get_banner_info.test.ts b/x-pack/plugins/banners/public/get_banner_info.test.ts new file mode 100644 index 00000000000000..cfb9bc26db47bc --- /dev/null +++ b/x-pack/plugins/banners/public/get_banner_info.test.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 { httpServiceMock } from '../../../../src/core/public/mocks'; +import { getBannerInfo } from './get_banner_info'; + +describe('getBannerInfo', () => { + let http: ReturnType; + + beforeEach(() => { + http = httpServiceMock.createStartContract(); + }); + + it('calls `http.get` with the correct parameters', async () => { + await getBannerInfo(http); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith('/api/banners/info'); + }); + + it('returns the value from the service', async () => { + const expected = { + allowed: true, + }; + http.get.mockResolvedValue(expected); + + const response = await getBannerInfo(http); + + expect(response).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/banners/public/get_banner_info.ts b/x-pack/plugins/banners/public/get_banner_info.ts new file mode 100644 index 00000000000000..56b32b26bef7c2 --- /dev/null +++ b/x-pack/plugins/banners/public/get_banner_info.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. + */ + +import { HttpStart } from 'src/core/public'; +import { BannerInfoResponse } from '../common'; + +export const getBannerInfo = async (http: HttpStart): Promise => { + return await http.get('/api/banners/info'); +}; diff --git a/x-pack/plugins/banners/public/index.ts b/x-pack/plugins/banners/public/index.ts new file mode 100644 index 00000000000000..d38a4d4785e097 --- /dev/null +++ b/x-pack/plugins/banners/public/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. + */ + +import { PluginInitializer } from 'src/core/public'; +import { BannersPlugin } from './plugin'; + +export const plugin: PluginInitializer<{}, {}, {}, {}> = (contextInitializer) => + new BannersPlugin(contextInitializer); diff --git a/x-pack/plugins/banners/public/plugin.test.mocks.ts b/x-pack/plugins/banners/public/plugin.test.mocks.ts new file mode 100644 index 00000000000000..cadd10dc96f944 --- /dev/null +++ b/x-pack/plugins/banners/public/plugin.test.mocks.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getBannerInfoMock = jest.fn(); +jest.doMock('./get_banner_info', () => ({ + getBannerInfo: getBannerInfoMock, +})); diff --git a/x-pack/plugins/banners/public/plugin.test.tsx b/x-pack/plugins/banners/public/plugin.test.tsx new file mode 100644 index 00000000000000..036ad17e2598e9 --- /dev/null +++ b/x-pack/plugins/banners/public/plugin.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getBannerInfoMock } from './plugin.test.mocks'; +import { coreMock } from '../../../../src/core/public/mocks'; +import { BannersPlugin } from './plugin'; +import { BannerClientConfig } from './types'; + +const nextTick = async () => await new Promise((resolve) => resolve()); + +describe('BannersPlugin', () => { + let plugin: BannersPlugin; + let pluginInitContext: ReturnType; + let coreSetup: ReturnType; + let coreStart: ReturnType; + + beforeEach(() => { + pluginInitContext = coreMock.createPluginInitializerContext(); + coreSetup = coreMock.createSetup(); + coreStart = coreMock.createStart(); + + getBannerInfoMock.mockResolvedValue({ + allowed: false, + }); + }); + + const startPlugin = async (config: BannerClientConfig) => { + pluginInitContext = coreMock.createPluginInitializerContext(config); + plugin = new BannersPlugin(pluginInitContext); + plugin.setup(coreSetup); + plugin.start(coreStart); + // await for the `getBannerInfo` promise to resolve + await nextTick(); + }; + + afterEach(() => { + getBannerInfoMock.mockReset(); + }); + + it('calls `getBannerInfo` if `config.placement !== disabled`', async () => { + await startPlugin({ + placement: 'header', + }); + + expect(getBannerInfoMock).toHaveBeenCalledTimes(1); + }); + + it('does not call `getBannerInfo` if `config.placement === disabled`', async () => { + await startPlugin({ + placement: 'disabled', + }); + + expect(getBannerInfoMock).not.toHaveBeenCalled(); + }); + + it('registers the header banner if `getBannerInfo` return `allowed=true`', async () => { + getBannerInfoMock.mockResolvedValue({ + allowed: true, + }); + + await startPlugin({ + placement: 'header', + }); + + expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledTimes(1); + expect(coreStart.chrome.setHeaderBanner).toHaveBeenCalledWith({ + content: expect.any(Function), + }); + }); + + it('does not register the header banner if `getBannerInfo` return `allowed=false`', async () => { + getBannerInfoMock.mockResolvedValue({ + allowed: false, + }); + + await startPlugin({ + placement: 'header', + }); + + expect(coreStart.chrome.setHeaderBanner).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/banners/public/plugin.tsx b/x-pack/plugins/banners/public/plugin.tsx new file mode 100644 index 00000000000000..dca99a816a25ba --- /dev/null +++ b/x-pack/plugins/banners/public/plugin.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; +import { Banner } from './components'; +import { BannerClientConfig } from './types'; +import { getBannerInfo } from './get_banner_info'; + +export class BannersPlugin implements Plugin<{}, {}, {}, {}> { + private readonly config: BannerClientConfig; + + constructor(context: PluginInitializerContext) { + this.config = context.config.get(); + } + + setup({}: CoreSetup<{}, {}>) { + return {}; + } + + start({ chrome, uiSettings, http }: CoreStart) { + if (this.config.placement !== 'disabled') { + getBannerInfo(http).then( + ({ allowed, banner }) => { + if (allowed) { + chrome.setHeaderBanner({ + content: toMountPoint(), + }); + } + }, + () => { + chrome.setHeaderBanner(undefined); + } + ); + } + + return {}; + } +} diff --git a/x-pack/plugins/banners/public/types.ts b/x-pack/plugins/banners/public/types.ts new file mode 100644 index 00000000000000..1f0ce524a785e8 --- /dev/null +++ b/x-pack/plugins/banners/public/types.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 { BannerPlacement } from '../common'; + +export interface BannerClientConfig { + placement: BannerPlacement; +} diff --git a/x-pack/plugins/banners/server/config.ts b/x-pack/plugins/banners/server/config.ts new file mode 100644 index 00000000000000..9a8cc9680c296a --- /dev/null +++ b/x-pack/plugins/banners/server/config.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 { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'kibana/server'; +import { isHexColor } from './utils'; + +const configSchema = schema.object({ + placement: schema.oneOf([schema.literal('disabled'), schema.literal('header')], { + defaultValue: 'disabled', + }), + textContent: schema.string({ defaultValue: '' }), + textColor: schema.string({ + validate: (color) => { + if (!isHexColor(color)) { + return `must be an hex color`; + } + }, + defaultValue: '#8A6A0A', + }), + backgroundColor: schema.string({ + validate: (color) => { + if (!isHexColor(color)) { + return `must be an hex color`; + } + }, + defaultValue: '#FFF9E8', + }), +}); + +export type BannersConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + placement: true, + }, +}; diff --git a/x-pack/plugins/banners/server/index.ts b/x-pack/plugins/banners/server/index.ts new file mode 100644 index 00000000000000..2036eda7e6502e --- /dev/null +++ b/x-pack/plugins/banners/server/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. + */ + +import { PluginInitializer } from 'src/core/server'; +import { BannersPlugin } from './plugin'; + +export { config } from './config'; +export const plugin: PluginInitializer<{}, {}, {}, {}> = (context) => new BannersPlugin(context); diff --git a/x-pack/plugins/banners/server/plugin.ts b/x-pack/plugins/banners/server/plugin.ts new file mode 100644 index 00000000000000..66cd0831899750 --- /dev/null +++ b/x-pack/plugins/banners/server/plugin.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 { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/server'; +import { BannerConfiguration } from '../common'; +import { BannersConfigType } from './config'; +import { BannersRequestHandlerContext } from './types'; +import { registerRoutes } from './routes'; + +export class BannersPlugin implements Plugin<{}, {}, {}, {}> { + private readonly config: BannerConfiguration; + + constructor(context: PluginInitializerContext) { + this.config = convertConfig(context.config.get()); + } + + setup({ uiSettings, getStartServices, http }: CoreSetup<{}, {}>) { + const router = http.createRouter(); + registerRoutes(router, this.config); + + return {}; + } + + start() { + return {}; + } +} + +const convertConfig = (raw: BannersConfigType): BannerConfiguration => raw; diff --git a/x-pack/plugins/banners/server/routes/index.ts b/x-pack/plugins/banners/server/routes/index.ts new file mode 100644 index 00000000000000..a4eedc3234c86b --- /dev/null +++ b/x-pack/plugins/banners/server/routes/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. + */ + +import { BannerConfiguration } from '../../common'; +import { BannersRouter } from '../types'; +import { registerInfoRoute } from './info'; + +export const registerRoutes = (router: BannersRouter, config: BannerConfiguration) => { + registerInfoRoute(router, config); +}; diff --git a/x-pack/plugins/banners/server/routes/info.ts b/x-pack/plugins/banners/server/routes/info.ts new file mode 100644 index 00000000000000..e0db842028c373 --- /dev/null +++ b/x-pack/plugins/banners/server/routes/info.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ILicense } from '../../../licensing/server'; +import { BannerInfoResponse, BannerConfiguration } from '../../common'; +import { BannersRouter } from '../types'; + +export const registerInfoRoute = (router: BannersRouter, config: BannerConfiguration) => { + router.get( + { + path: '/api/banners/info', + validate: false, + options: { + authRequired: false, + }, + }, + (ctx, req, res) => { + const allowed = isValidLicense(ctx.licensing.license); + + return res.ok({ + body: { + allowed, + banner: config, + } as BannerInfoResponse, + }); + } + ); +}; + +const isValidLicense = (license: ILicense): boolean => { + return license.hasAtLeast('gold'); +}; diff --git a/x-pack/plugins/banners/server/types.ts b/x-pack/plugins/banners/server/types.ts new file mode 100644 index 00000000000000..96f7224e62c229 --- /dev/null +++ b/x-pack/plugins/banners/server/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RequestHandlerContext, IRouter } from 'src/core/server'; +import { LicensingApiRequestHandlerContext } from '../../licensing/server'; + +export interface BannersRequestHandlerContext extends RequestHandlerContext { + licensing: LicensingApiRequestHandlerContext; +} + +export type BannersRouter = IRouter; diff --git a/x-pack/plugins/banners/server/utils.test.ts b/x-pack/plugins/banners/server/utils.test.ts new file mode 100644 index 00000000000000..57b7a3ede0f8fd --- /dev/null +++ b/x-pack/plugins/banners/server/utils.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 { isHexColor } from './utils'; + +describe('isHexColor', () => { + it('returns true for valid 3-length hex colors', () => { + expect(isHexColor('#FEC')).toBe(true); + expect(isHexColor('#0a4')).toBe(true); + }); + + it('returns true for valid 6-length hex colors', () => { + expect(isHexColor('#FF00CC')).toBe(true); + expect(isHexColor('#fab47e')).toBe(true); + }); + + it('returns false for other strings', () => { + expect(isHexColor('#FAZ')).toBe(false); + expect(isHexColor('#FFAAUU')).toBe(false); + expect(isHexColor('foobar')).toBe(false); + }); +}); diff --git a/x-pack/plugins/banners/server/utils.ts b/x-pack/plugins/banners/server/utils.ts new file mode 100644 index 00000000000000..1597b3a2ace3cd --- /dev/null +++ b/x-pack/plugins/banners/server/utils.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. + */ + +const hexColorRegexp = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i; + +export const isHexColor = (color: string) => { + return hexColorRegexp.test(color); +}; diff --git a/x-pack/plugins/banners/tsconfig.json b/x-pack/plugins/banners/tsconfig.json new file mode 100644 index 00000000000000..85608a8a78ad52 --- /dev/null +++ b/x-pack/plugins/banners/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + "common/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" } + ] +} + diff --git a/x-pack/plugins/maps/public/_main.scss b/x-pack/plugins/maps/public/_main.scss index 5ce3bf4e2b998e..61de65dd4bf6f0 100644 --- a/x-pack/plugins/maps/public/_main.scss +++ b/x-pack/plugins/maps/public/_main.scss @@ -1,19 +1,15 @@ -@import '../../../../src/core/public/variables'; +@import '../../../../src/core/public/mixins'; // sass-lint:disable no-ids #maps-plugin { + @include kibanaFullBodyHeight(); + display: flex; flex-direction: column; - height: calc(100vh - #{$kbnHeaderOffset}); width: 100%; overflow: hidden; } -.mapFullScreen { - // sass-lint:disable no-important - height: 100vh !important; -} - #react-maps-root { flex-grow: 1; display: flex; diff --git a/x-pack/plugins/painless_lab/public/styles/_index.scss b/x-pack/plugins/painless_lab/public/styles/_index.scss index e5ed8f38a31ee4..00197e744e95cd 100644 --- a/x-pack/plugins/painless_lab/public/styles/_index.scss +++ b/x-pack/plugins/painless_lab/public/styles/_index.scss @@ -1,4 +1,5 @@ @import '@elastic/eui/src/global_styling/variables/header'; +@import '../../../../../src/core/public/mixins'; /** * This is a very brittle way of preventing the editor and other content from disappearing @@ -39,11 +40,11 @@ $bottomBarHeight: $euiSize * 3; line-height: 0; } -// This value is calculated to static value using SCSS because calc in calc has issues in IE11 -$headerOffset: $euiHeaderHeightCompensation * 3; +// adding dev tool top bar + bottom bar height to the body offset +$bodyOffset: $euiHeaderHeightCompensation + $bottomBarHeight; .painlessLabMainContainer { - height: calc(100vh - #{$headerOffset} - #{$bottomBarHeight}); + @include kibanaFullBodyHeight($bodyOffset); } .painlessLabPanelsContainer { diff --git a/x-pack/plugins/searchprofiler/public/application/_app.scss b/x-pack/plugins/searchprofiler/public/application/_app.scss index 6a2d1eb5e2189c..3c163fa8fefec3 100644 --- a/x-pack/plugins/searchprofiler/public/application/_app.scss +++ b/x-pack/plugins/searchprofiler/public/application/_app.scss @@ -1,3 +1,5 @@ +@import '../../../../../src/core/public/mixins'; + .prfDevTool__page { flex: 1 1 auto; @@ -28,11 +30,11 @@ } } -// This value is calculated to static value using SCSS because calc in calc has issues in IE11 -$headerHeightOffset: $euiHeaderHeightCompensation * 3; +// adding dev tool top bar to the body offset +$bodyOffset: $euiHeaderHeightCompensation; .appRoot { - height: calc(100vh - #{$headerHeightOffset}); + @include kibanaFullBodyHeight($bodyOffset); overflow: hidden; flex-shrink: 1; } diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 6209503e756106..4b56ebc83d9893 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -40,6 +40,7 @@ { "path": "../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../plugins/actions/tsconfig.json" }, { "path": "../plugins/alerts/tsconfig.json" }, + { "path": "../plugins/banners/tsconfig.json" }, { "path": "../plugins/beats_management/tsconfig.json" }, { "path": "../plugins/cloud/tsconfig.json" }, { "path": "../plugins/code/tsconfig.json" }, diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 5589c62010db1e..6b874f62538438 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -7,6 +7,7 @@ "plugins/apm/e2e/cypress/**/*", "plugins/apm/ftr_e2e/**/*", "plugins/apm/scripts/**/*", + "plugins/banners/**/*", "plugins/canvas/**/*", "plugins/console_extensions/**/*", "plugins/code/**/*", From c91e5fe3f2120f94d9239978693e9df09040346e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 11 Feb 2021 10:16:55 +0100 Subject: [PATCH 09/24] [Lens] Median as default function (#90952) --- .../indexpattern_suggestions.test.tsx | 16 ++++++++-------- .../operations/definitions/metrics.tsx | 1 + .../operations/operations.test.ts | 14 +++++++------- .../functional/apps/discover/visualize_field.ts | 2 +- .../test/functional/apps/lens/drag_and_drop.ts | 4 ++-- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 66e8f29fa1587f..1e928f1c0b2bf7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -319,7 +319,7 @@ describe('IndexPattern Data Source suggestions', () => { sourceField: 'timestamp', }), id2: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'bytes', }), }, @@ -400,7 +400,7 @@ describe('IndexPattern Data Source suggestions', () => { columnOrder: ['id1'], columns: { id1: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'bytes', }), }, @@ -542,7 +542,7 @@ describe('IndexPattern Data Source suggestions', () => { sourceField: 'timestamp', }), id1: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'bytes', }), }, @@ -624,7 +624,7 @@ describe('IndexPattern Data Source suggestions', () => { columnOrder: ['id1'], columns: { id1: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'bytes', }), }, @@ -914,7 +914,7 @@ describe('IndexPattern Data Source suggestions', () => { columns: { cola: initialState.layers.currentLayer.columns.cola, colb: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'memory', }), }, @@ -934,7 +934,7 @@ describe('IndexPattern Data Source suggestions', () => { cola: initialState.layers.currentLayer.columns.cola, colb: initialState.layers.currentLayer.columns.colb, newid: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'memory', }), }, @@ -979,7 +979,7 @@ describe('IndexPattern Data Source suggestions', () => { columns: { ...modifiedState.layers.currentLayer.columns, newid: expect.objectContaining({ - operationType: 'avg', + operationType: 'median', sourceField: 'memory', }), }, @@ -2039,7 +2039,7 @@ describe('IndexPattern Data Source suggestions', () => { table: expect.objectContaining({ columns: [ expect.objectContaining({ - operation: expect.objectContaining({ label: 'Sum of field1' }), + operation: expect.objectContaining({ label: 'Median of field1' }), }), ], }), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index 21fc11693dabab..e11ee580deb9bf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -181,6 +181,7 @@ export const sumOperation = buildMetricOperation({ export const medianOperation = buildMetricOperation({ type: 'median', + priority: 3, displayName: i18n.translate('xpack.lens.indexPattern.median', { defaultMessage: 'Median', }), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 360e1697ae58dc..78e84ce518ab57 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -197,14 +197,14 @@ describe('getOperationTypesForField', () => { }); describe('getAvailableOperationsByMetaData', () => { - it('should put the average operation first', () => { + it('should put the median operation first', () => { const numberOperation = getAvailableOperationsByMetadata(expectedIndexPatterns[1]).find( ({ operationMetaData }) => !operationMetaData.isBucketed && operationMetaData.dataType === 'number' )!; expect(numberOperation.operations[0]).toEqual( expect.objectContaining({ - operationType: 'avg', + operationType: 'median', }) ); }); @@ -279,6 +279,11 @@ describe('getOperationTypesForField', () => { "scale": "ratio", }, "operations": Array [ + Object { + "field": "bytes", + "operationType": "median", + "type": "field", + }, Object { "field": "bytes", "operationType": "avg", @@ -330,11 +335,6 @@ describe('getOperationTypesForField', () => { "operationType": "cardinality", "type": "field", }, - Object { - "field": "bytes", - "operationType": "median", - "type": "field", - }, Object { "field": "bytes", "operationType": "percentile", diff --git a/x-pack/test/functional/apps/discover/visualize_field.ts b/x-pack/test/functional/apps/discover/visualize_field.ts index f9312f453e8dd0..d0d7c25c205e51 100644 --- a/x-pack/test/functional/apps/discover/visualize_field.ts +++ b/x-pack/test/functional/apps/discover/visualize_field.ts @@ -52,7 +52,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await retry.try(async () => { const dimensions = await testSubjects.findAll('lns-dimensionTrigger'); expect(dimensions).to.have.length(2); - expect(await dimensions[1].getVisibleText()).to.be('Average of bytes'); + expect(await dimensions[1].getVisibleText()).to.be('Median of bytes'); }); }); diff --git a/x-pack/test/functional/apps/lens/drag_and_drop.ts b/x-pack/test/functional/apps/lens/drag_and_drop.ts index a272b67de1b0ad..0e4d428c260294 100644 --- a/x-pack/test/functional/apps/lens/drag_and_drop.ts +++ b/x-pack/test/functional/apps/lens/drag_and_drop.ts @@ -149,7 +149,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.dragFieldWithKeyboard('bytes', 4); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ 'Count of records', - 'Average of bytes', + 'Median of bytes', ]); await PageObjects.lens.dragFieldWithKeyboard('@message.raw', 1, true); expect( @@ -169,7 +169,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_yDimensionPanel', 0, 1); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ 'Count of records', - 'Average of bytes', + 'Median of bytes', 'Count of records [1]', ]); From 9ab5bcb141346de1ca880d023f0ee2e10f8a7cf2 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 11 Feb 2021 10:33:32 +0100 Subject: [PATCH 10/24] [Search Session] Control "Kibana / Search Sessions" management section by privileges (#90818) --- .../kibana-plugin-plugins-data-public.md | 1 + ...ta-public.search_sessions_management_id.md | 11 ++ src/plugins/data/public/index.ts | 1 + src/plugins/data/public/public.api.md | 37 +++--- src/plugins/data/public/search/index.ts | 1 + .../public/search/search_interceptor.test.ts | 46 +++++--- .../data/public/search/search_interceptor.ts | 4 +- .../data/public/search/session/constants.ts | 9 ++ .../data/public/search/session/index.ts | 1 + .../data/public/search/session/mocks.ts | 1 + .../search/session/session_service.test.ts | 38 +++++- .../public/search/session/session_service.ts | 34 +++++- .../helpers/timelion_request_handler.ts | 5 +- .../public/request_handler.ts | 5 +- .../public/search/sessions_mgmt/index.ts | 3 +- ...onnected_search_session_indicator.test.tsx | 51 +++++--- .../connected_search_session_indicator.tsx | 17 +++ .../search_session_indicator.tsx | 28 +++-- .../__snapshots__/oss_features.test.ts.snap | 24 +++- .../plugins/features/server/oss_features.ts | 12 ++ .../apps/management/search_sessions/index.ts | 1 + .../sessions_management_permissions.ts | 111 ++++++++++++++++++ 22 files changed, 370 insertions(+), 71 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md create mode 100644 src/plugins/data/public/search/session/constants.ts create mode 100644 x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index f576d795b93a52..d2e7ef9db05e8e 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -126,6 +126,7 @@ | [noSearchSessionStorageCapabilityMessage](./kibana-plugin-plugins-data-public.nosearchsessionstoragecapabilitymessage.md) | Message to display in case storing session session is disabled due to turned off capability | | [parseSearchSourceJSON](./kibana-plugin-plugins-data-public.parsesearchsourcejson.md) | | | [QueryStringInput](./kibana-plugin-plugins-data-public.querystringinput.md) | | +| [SEARCH\_SESSIONS\_MANAGEMENT\_ID](./kibana-plugin-plugins-data-public.search_sessions_management_id.md) | | | [search](./kibana-plugin-plugins-data-public.search.md) | | | [SearchBar](./kibana-plugin-plugins-data-public.searchbar.md) | | | [syncQueryStateWithUrl](./kibana-plugin-plugins-data-public.syncquerystatewithurl.md) | Helper to setup syncing of global data with the URL | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md new file mode 100644 index 00000000000000..ad16d21403a984 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SEARCH\_SESSIONS\_MANAGEMENT\_ID](./kibana-plugin-plugins-data-public.search_sessions_management_id.md) + +## SEARCH\_SESSIONS\_MANAGEMENT\_ID variable + +Signature: + +```typescript +SEARCH_SESSIONS_MANAGEMENT_ID = "search_sessions" +``` diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 17533eec0a0fa2..83a248ee2c3dee 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -381,6 +381,7 @@ export { TimeoutErrorMode, PainlessError, noSearchSessionStorageCapabilityMessage, + SEARCH_SESSIONS_MANAGEMENT_ID, } from './search'; export type { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 408573e12eba54..95c849ce74248b 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2238,6 +2238,11 @@ export const search: { tabifyGetColumns: typeof tabifyGetColumns; }; +// Warning: (ae-missing-release-tag) "SEARCH_SESSIONS_MANAGEMENT_ID" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const SEARCH_SESSIONS_MANAGEMENT_ID = "search_sessions"; + // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2601,23 +2606,23 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:415:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:419:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/search/session/session_service.ts:41:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/search/session/session_service.ts:42:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index 31a94d69ddf023..b1e0bc490823a3 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -38,6 +38,7 @@ export { SessionsClient, ISessionsClient, noSearchSessionStorageCapabilityMessage, + SEARCH_SESSIONS_MANAGEMENT_ID, } from './session'; export { getEsPreference } from './es_search'; diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index 02d5a19c31743f..f890fdc3e30a34 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -95,21 +95,23 @@ describe('SearchInterceptor', () => { }); describe('Search session', () => { - const setup = ({ - isRestore = false, - isStored = false, - sessionId, - }: { - isRestore?: boolean; - isStored?: boolean; - sessionId: string; - }) => { + const setup = ( + opts: { + isRestore?: boolean; + isStored?: boolean; + sessionId: string; + } | null + ) => { const sessionServiceMock = searchMock.session as jest.Mocked; - sessionServiceMock.getSearchOptions.mockImplementation(() => ({ - sessionId, - isRestore, - isStored, - })); + sessionServiceMock.getSearchOptions.mockImplementation(() => + opts + ? { + sessionId: opts.sessionId, + isRestore: opts.isRestore ?? false, + isStored: opts.isStored ?? false, + } + : null + ); fetchMock.mockResolvedValue({ result: 200 }); }; @@ -142,6 +144,22 @@ describe('SearchInterceptor', () => { (searchMock.session as jest.Mocked).getSearchOptions ).toHaveBeenCalledWith(sessionId); }); + + test("doesn't forward sessionId if search options return null", async () => { + const sessionId = 'sid'; + setup(null); + + await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); + expect(fetchMock.mock.calls[0][0]).toEqual( + expect.not.objectContaining({ + options: { sessionId }, + }) + ); + + expect( + (searchMock.session as jest.Mocked).getSearchOptions + ).toHaveBeenCalledWith(sessionId); + }); }); describe('Should throw typed errors', () => { diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index fde8ac9f25d6ed..f33740cc45bf98 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -126,14 +126,14 @@ export class SearchInterceptor { request: IKibanaSearchRequest, options?: ISearchOptions ): Promise { - const { abortSignal, ...requestOptions } = options || {}; + const { abortSignal, sessionId, ...requestOptions } = options || {}; return this.batchedFetch( { request, options: { ...requestOptions, - ...(options?.sessionId && this.deps.session.getSearchOptions(options.sessionId)), + ...this.deps.session.getSearchOptions(sessionId), }, }, abortSignal diff --git a/src/plugins/data/public/search/session/constants.ts b/src/plugins/data/public/search/session/constants.ts new file mode 100644 index 00000000000000..5496a541bfd453 --- /dev/null +++ b/src/plugins/data/public/search/session/constants.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 const SEARCH_SESSIONS_MANAGEMENT_ID = 'search_sessions'; diff --git a/src/plugins/data/public/search/session/index.ts b/src/plugins/data/public/search/session/index.ts index 82ba1e703a6d68..15410400a33e64 100644 --- a/src/plugins/data/public/search/session/index.ts +++ b/src/plugins/data/public/search/session/index.ts @@ -10,3 +10,4 @@ export { SessionService, ISessionService, SearchSessionInfoProvider } from './se export { SearchSessionState } from './search_session_state'; export { SessionsClient, ISessionsClient } from './sessions_client'; export { noSearchSessionStorageCapabilityMessage } from './i18n'; +export { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; diff --git a/src/plugins/data/public/search/session/mocks.ts b/src/plugins/data/public/search/session/mocks.ts index f6a70d157b5a06..c615be641078b4 100644 --- a/src/plugins/data/public/search/session/mocks.ts +++ b/src/plugins/data/public/search/session/mocks.ts @@ -41,5 +41,6 @@ export function getSessionServiceMock(): jest.Mocked { enableStorage: jest.fn(), isSessionStorageReady: jest.fn(() => true), getSearchSessionIndicatorUiConfig: jest.fn(() => ({ isDisabled: () => ({ disabled: false }) })), + hasAccess: jest.fn(() => true), }; } diff --git a/src/plugins/data/public/search/session/session_service.test.ts b/src/plugins/data/public/search/session/session_service.test.ts index 54c402f51ec70e..3d49c91fea44e3 100644 --- a/src/plugins/data/public/search/session/session_service.test.ts +++ b/src/plugins/data/public/search/session/session_service.test.ts @@ -14,11 +14,13 @@ import { BehaviorSubject } from 'rxjs'; import { SearchSessionState } from './search_session_state'; import { createNowProviderMock } from '../../now_provider/mocks'; import { NowProviderInternalContract } from '../../now_provider'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; describe('Session service', () => { let sessionService: ISessionService; let state$: BehaviorSubject; let nowProvider: jest.Mocked; + let userHasAccessToSearchSessions = true; beforeEach(() => { const initializerContext = coreMock.createPluginInitializerContext(); @@ -30,7 +32,18 @@ describe('Session service', () => { startService().then(([coreStart, ...rest]) => [ { ...coreStart, - application: { ...coreStart.application, currentAppId$: new BehaviorSubject('app') }, + application: { + ...coreStart.application, + currentAppId$: new BehaviorSubject('app'), + capabilities: { + ...coreStart.application.capabilities, + management: { + kibana: { + [SEARCH_SESSIONS_MANAGEMENT_ID]: userHasAccessToSearchSessions, + }, + }, + }, + }, }, ...rest, ]), @@ -146,6 +159,8 @@ describe('Session service', () => { isRestore: true, sessionId, }); + + expect(sessionService.getSearchOptions(undefined)).toBeNull(); }); test('isCurrentSession', () => { expect(sessionService.isCurrentSession()).toBeFalsy(); @@ -214,4 +229,25 @@ describe('Session service', () => { sessionService.start(); await expect(() => sessionService.save()).rejects.toMatchInlineSnapshot(`[Error: Haha]`); }); + + describe("user doesn't have access to search session", () => { + beforeAll(() => { + userHasAccessToSearchSessions = false; + }); + afterAll(() => { + userHasAccessToSearchSessions = true; + }); + + test("getSearchOptions doesn't return sessionId", () => { + const sessionId = sessionService.start(); + expect(sessionService.getSearchOptions(sessionId)).toBeNull(); + }); + + test('save() throws', async () => { + sessionService.start(); + await expect(() => sessionService.save()).rejects.toThrowErrorMatchingInlineSnapshot( + `"No access to search sessions"` + ); + }); + }); }); diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 79ae64c5846a50..4286edf27cd40e 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -20,6 +20,7 @@ import { import { ISessionsClient } from './sessions_client'; import { ISearchOptions } from '../../../common'; import { NowProviderInternalContract } from '../../now_provider'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; export type ISessionService = PublicContract; @@ -68,6 +69,7 @@ export class SessionService { private searchSessionIndicatorUiConfig?: Partial; private subscription = new Subscription(); private curApp?: string; + private hasAccessToSearchSessions: boolean = false; constructor( initializerContext: PluginInitializerContext, @@ -94,6 +96,10 @@ export class SessionService { ); getStartServices().then(([coreStart]) => { + // using management?.kibana? we infer if any of the apps allows current user to store sessions + this.hasAccessToSearchSessions = + coreStart.application.capabilities.management?.kibana?.[SEARCH_SESSIONS_MANAGEMENT_ID]; + // Apps required to clean up their sessions before unmounting // Make sure that apps don't leave sessions open. this.subscription.add( @@ -117,6 +123,15 @@ export class SessionService { }); } + /** + * If user has access to search sessions + * This resolves to `true` in case at least one app allows user to create search session + * In this case search session management is available + */ + public hasAccess() { + return this.hasAccessToSearchSessions; + } + /** * Used to track pending searches within current session * @@ -215,6 +230,7 @@ export class SessionService { const sessionId = this.getSessionId(); if (!sessionId) throw new Error('No current session'); if (!this.curApp) throw new Error('No current app id'); + if (!this.hasAccess()) throw new Error('No access to search sessions'); const currentSessionInfoProvider = this.searchSessionInfoProvider; if (!currentSessionInfoProvider) throw new Error('No info provider for current session'); const [name, { initialState, restoreState, urlGeneratorId }] = await Promise.all([ @@ -247,11 +263,25 @@ export class SessionService { /** * Infers search session options for sessionId using current session state + * + * In case user doesn't has access to `search-session` SO returns null, + * meaning that sessionId and other session parameters shouldn't be used when doing searches + * * @param sessionId */ public getSearchOptions( - sessionId: string - ): Required> { + sessionId?: string + ): Required> | null { + if (!sessionId) { + return null; + } + + // in case user doesn't have permissions to search session, do not forward sessionId to the server + // because user most likely also doesn't have access to `search-session` SO + if (!this.hasAccessToSearchSessions) { + return null; + } + const isCurrentSession = this.isCurrentSession(sessionId); return { sessionId, diff --git a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts index c07fd0a2781976..7e8f28bd32b2fd 100644 --- a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts +++ b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts @@ -94,6 +94,7 @@ export function getTimelionRequestHandler({ }); try { + const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); return await http.post('/api/timelion/run', { body: JSON.stringify({ sheet: [expression], @@ -108,8 +109,8 @@ export function getTimelionRequestHandler({ interval: visParams.interval, timezone, }, - ...(searchSessionId && { - searchSession: dataSearch.session.getSearchOptions(searchSessionId), + ...(searchSessionOptions && { + searchSession: searchSessionOptions, }), }), }); diff --git a/src/plugins/vis_type_timeseries/public/request_handler.ts b/src/plugins/vis_type_timeseries/public/request_handler.ts index c7beccbceca1a9..d0526f7e1d886e 100644 --- a/src/plugins/vis_type_timeseries/public/request_handler.ts +++ b/src/plugins/vis_type_timeseries/public/request_handler.ts @@ -48,6 +48,7 @@ export const metricsRequestHandler = async ({ }); try { + const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); return await getCoreStart().http.post(ROUTES.VIS_DATA, { body: JSON.stringify({ timerange: { @@ -58,8 +59,8 @@ export const metricsRequestHandler = async ({ filters: input?.filters, panels: [visParams], state: uiStateObj, - ...(searchSessionId && { - searchSession: dataSearch.session.getSearchOptions(searchSessionId), + ...(searchSessionOptions && { + searchSession: searchSessionOptions, }), }), }); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts index 332b30809077c7..e13cd06f52a4d7 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts @@ -15,6 +15,7 @@ import type { ConfigSchema } from '../../../config'; import type { DataEnhancedStartDependencies } from '../../plugin'; import type { SearchSessionsMgmtAPI } from './lib/api'; import type { AsyncSearchIntroDocumentation } from './lib/documentation'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from '../../../../../../src/plugins/data/public'; export interface IManagementSectionsPluginsSetup { management: ManagementSetup; @@ -38,7 +39,7 @@ export interface AppDependencies { } export const APP = { - id: 'search_sessions', + id: SEARCH_SESSIONS_MANAGEMENT_ID, getI18nName: (): string => i18n.translate('xpack.data.mgmt.searchSessions.appTitle', { defaultMessage: 'Search Sessions', diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index 3437920ed7c98a..aacb86f269727a 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -24,6 +24,7 @@ import userEvent from '@testing-library/user-event'; import { IntlProvider } from 'react-intl'; const coreStart = coreMock.createStart(); +const application = coreStart.application; const dataStart = dataPluginMock.createStartContract(); const sessionService = dataStart.search.session as jest.Mocked; let storage: Storage; @@ -52,7 +53,7 @@ beforeEach(() => { test("shouldn't show indicator in case no active search session", async () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -79,7 +80,7 @@ test("shouldn't show indicator in case no active search session", async () => { test("shouldn't show indicator in case app hasn't opt-in", async () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -108,7 +109,7 @@ test('should show indicator in case there is an active search session', async () const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -124,12 +125,6 @@ test('should show indicator in case there is an active search session', async () test('should be disabled in case uiConfig says so ', async () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); - coreStart.application.currentAppId$ = new BehaviorSubject('discover'); - (coreStart.application.capabilities as any) = { - discover: { - storeSearchSession: false, - }, - }; sessionService.getSearchSessionIndicatorUiConfig.mockImplementation(() => ({ isDisabled: () => ({ disabled: true, @@ -138,7 +133,7 @@ test('should be disabled in case uiConfig says so ', async () => { })); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -157,12 +152,36 @@ test('should be disabled in case uiConfig says so ', async () => { expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); }); +test('should be disabled in case not enough permissions', async () => { + const state$ = new BehaviorSubject(SearchSessionState.Completed); + const SearchSessionIndicator = createConnectedSearchSessionIndicator({ + sessionService: { ...sessionService, state$, hasAccess: () => false }, + application, + timeFilter, + storage, + disableSaveAfterSessionCompletesTimeout, + }); + + render( + + + + ); + + await waitFor(() => screen.getByTestId('searchSessionIndicator')); + + await userEvent.click(screen.getByLabelText('Search session complete')); + + expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Manage sessions' })).toBeDisabled(); +}); + test('should be disabled during auto-refresh', async () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -199,7 +218,7 @@ describe('Completed inactivity', () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -257,7 +276,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -294,7 +313,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -325,7 +344,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Restored); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -347,7 +366,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Completed); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 3935b5bb2814b7..81769e5a25544f 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -79,6 +79,9 @@ export const createConnectedSearchSessionIndicator = ({ let saveDisabled = false; let saveDisabledReasonText: string = ''; + let managementDisabled = false; + let managementDisabledReasonText: string = ''; + if (autoRefreshEnabled) { saveDisabled = true; saveDisabledReasonText = i18n.translate( @@ -104,6 +107,18 @@ export const createConnectedSearchSessionIndicator = ({ saveDisabledReasonText = isSaveDisabledByApp.reasonText; } + // check if user doesn't have access to search_sessions and search_sessions mgtm + // this happens in case there is no app that allows current user to use search session + if (!sessionService.hasAccess()) { + managementDisabled = saveDisabled = true; + managementDisabledReasonText = saveDisabledReasonText = i18n.translate( + 'xpack.data.searchSessionIndicator.disabledDueToDisabledGloballyMessage', + { + defaultMessage: "You don't have permissions to manage search sessions", + } + ); + } + const { markOpenedDone, markRestoredDone } = useSearchSessionTour( storage, searchSessionIndicator, @@ -143,6 +158,8 @@ export const createConnectedSearchSessionIndicator = ({ state={state} saveDisabled={saveDisabled} saveDisabledReasonText={saveDisabledReasonText} + managementDisabled={managementDisabled} + managementDisabledReasonText={managementDisabledReasonText} onContinueInBackground={onContinueInBackground} onSaveResults={onSaveResults} onCancel={onCancel} diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx index eb58039ff58f7d..0d31ce0c98f194 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx @@ -31,7 +31,8 @@ export interface SearchSessionIndicatorProps { onCancel?: () => void; viewSearchSessionsLink?: string; onSaveResults?: () => void; - + managementDisabled?: boolean; + managementDisabledReasonText?: string; saveDisabled?: boolean; saveDisabledReasonText?: string; @@ -78,17 +79,22 @@ const ContinueInBackgroundButton = ({ const ViewAllSearchSessionsButton = ({ viewSearchSessionsLink = 'management/kibana/search_sessions', buttonProps = {}, + managementDisabled, + managementDisabledReasonText, }: ActionButtonProps) => ( - - - + + + + + ); const SaveButton = ({ diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index 8432fdac93a9a7..c941badcad2233 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -67,7 +67,11 @@ Array [ "catalogue": Array [ "dashboard", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "dashboard", @@ -200,7 +204,11 @@ Array [ "catalogue": Array [ "discover", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "search", @@ -553,7 +561,11 @@ Array [ "catalogue": Array [ "dashboard", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "dashboard", @@ -686,7 +698,11 @@ Array [ "catalogue": Array [ "discover", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "search", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 2d9e01427a277c..6c599461f438aa 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -21,6 +21,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.discoverFeatureName', { defaultMessage: 'Discover', }), + management: { + kibana: ['search_sessions'], + }, order: 100, category: DEFAULT_APP_CATEGORIES.kibana, app: ['discover', 'kibana'], @@ -95,6 +98,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS read: [], }, ui: ['storeSearchSession'], + management: { + kibana: ['search_sessions'], + }, }, ], }, @@ -166,6 +172,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.dashboardFeatureName', { defaultMessage: 'Dashboard', }), + management: { + kibana: ['search_sessions'], + }, order: 200, category: DEFAULT_APP_CATEGORIES.kibana, app: ['dashboards', 'kibana'], @@ -260,6 +269,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS read: [], }, ui: ['storeSearchSession'], + management: { + kibana: ['search_sessions'], + }, }, ], }, diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts index 994d91ae4a27b9..0798a25a2e9820 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts @@ -22,5 +22,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { }); loadTestFile(require.resolve('./sessions_management')); + loadTestFile(require.resolve('./sessions_management_permissions')); }); } diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts new file mode 100644 index 00000000000000..48f4156afbe82b --- /dev/null +++ b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.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 { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const security = getService('security'); + const PageObjects = getPageObjects([ + 'common', + 'header', + 'dashboard', + 'visChart', + 'searchSessionsManagement', + 'security', + ]); + + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('Search sessions Management UI permissions', () => { + describe('Sessions management is not available if non of apps enable search sessions', () => { + before(async () => { + await security.role.create('data_analyst', { + elasticsearch: {}, + kibana: [ + { + feature: { + dashboard: ['read'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('analyst', { + password: 'analyst-password', + roles: ['data_analyst'], + full_name: 'test user', + }); + + await PageObjects.security.forceLogout(); + + await PageObjects.security.login('analyst', 'analyst-password', { + expectSpaceSelector: false, + }); + }); + + after(async () => { + await security.role.delete('data_analyst'); + await security.user.delete('analyst'); + await PageObjects.security.forceLogout(); + }); + + it('Sessions management is not available if non of apps enable search sessions', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.not.contain('Stack Management'); + }); + }); + + describe('Sessions management is available if one of apps enables search sessions', () => { + before(async () => { + await security.role.create('data_analyst', { + elasticsearch: {}, + kibana: [ + { + feature: { + dashboard: ['read', 'store_search_session'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('analyst', { + password: 'analyst-password', + roles: ['data_analyst'], + full_name: 'test user', + }); + + await PageObjects.security.forceLogout(); + + await PageObjects.security.login('analyst', 'analyst-password', { + expectSpaceSelector: false, + }); + }); + + after(async () => { + await security.role.delete('data_analyst'); + await security.user.delete('analyst'); + await PageObjects.security.forceLogout(); + }); + + it('Sessions management is available if one of apps enables search sessions', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(1); + expect(sections[0]).to.eql({ + sectionId: 'kibana', + sectionLinks: ['search_sessions'], + }); + }); + }); + }); +} From a31cc73ccbbce57d3702e7605dad4ddc599faa85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Thu, 11 Feb 2021 10:44:05 +0100 Subject: [PATCH 11/24] [APM] Add experimental support for Data Streams (#89650) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/apm_oss/server/index.ts | 1 + x-pack/plugins/apm/common/processor_event.ts | 2 - .../components/shared/KueryBar/index.tsx | 2 +- .../public/hooks/use_dynamic_index_pattern.ts | 25 ++---- x-pack/plugins/apm/server/index.test.ts | 78 +++++++++++++++++++ x-pack/plugins/apm/server/index.ts | 30 ++++++- .../create_apm_event_client/index.ts | 2 - .../unpack_processor_events.test.ts | 49 ++++++++++++ .../unpack_processor_events.ts | 6 +- .../create_static_index_pattern.ts | 3 +- .../get_dynamic_index_pattern.ts | 32 +------- .../__snapshots__/queries.test.ts.snap | 1 - .../get_services/has_historical_agent_data.ts | 1 - x-pack/plugins/apm/server/plugin.ts | 3 +- .../apm/server/routes/index_pattern.ts | 28 +------ 15 files changed, 171 insertions(+), 92 deletions(-) create mode 100644 x-pack/plugins/apm/server/index.test.ts create mode 100644 x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts diff --git a/src/plugins/apm_oss/server/index.ts b/src/plugins/apm_oss/server/index.ts index ae7b70339cabcf..bea9965748f27a 100644 --- a/src/plugins/apm_oss/server/index.ts +++ b/src/plugins/apm_oss/server/index.ts @@ -21,6 +21,7 @@ export const config = { sourcemapIndices: schema.string({ defaultValue: 'apm-*' }), onboardingIndices: schema.string({ defaultValue: 'apm-*' }), indexPattern: schema.string({ defaultValue: 'apm-*' }), + fleetMode: schema.boolean({ defaultValue: true }), }), }; diff --git a/x-pack/plugins/apm/common/processor_event.ts b/x-pack/plugins/apm/common/processor_event.ts index 63cbb18cacc97d..9eb9ee60c19981 100644 --- a/x-pack/plugins/apm/common/processor_event.ts +++ b/x-pack/plugins/apm/common/processor_event.ts @@ -10,8 +10,6 @@ export enum ProcessorEvent { error = 'error', metric = 'metric', span = 'span', - onboarding = 'onboarding', - sourcemap = 'sourcemap', } /** * Processor events that are searchable in the UI via the query bar. diff --git a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx index efa4f26d9a23f8..ff34359d83c760 100644 --- a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx @@ -66,7 +66,7 @@ export function KueryBar(props: { prepend?: React.ReactNode | string }) { const example = examples[processorEvent || 'defaults']; - const { indexPattern } = useDynamicIndexPatternFetcher(processorEvent); + const { indexPattern } = useDynamicIndexPatternFetcher(); const placeholder = i18n.translate('xpack.apm.kueryBar.placeholder', { defaultMessage: `Search {event, select, diff --git a/x-pack/plugins/apm/public/hooks/use_dynamic_index_pattern.ts b/x-pack/plugins/apm/public/hooks/use_dynamic_index_pattern.ts index 8d0af2385df295..9c637dc1336add 100644 --- a/x-pack/plugins/apm/public/hooks/use_dynamic_index_pattern.ts +++ b/x-pack/plugins/apm/public/hooks/use_dynamic_index_pattern.ts @@ -6,25 +6,14 @@ */ import { useFetcher } from './use_fetcher'; -import { UIProcessorEvent } from '../../common/processor_event'; -export function useDynamicIndexPatternFetcher( - processorEvent: UIProcessorEvent | undefined -) { - const { data, status } = useFetcher( - (callApmApi) => { - return callApmApi({ - endpoint: 'GET /api/apm/index_pattern/dynamic', - isCachable: true, - params: { - query: { - processorEvent, - }, - }, - }); - }, - [processorEvent] - ); +export function useDynamicIndexPatternFetcher() { + const { data, status } = useFetcher((callApmApi) => { + return callApmApi({ + endpoint: 'GET /api/apm/index_pattern/dynamic', + isCachable: true, + }); + }, []); return { indexPattern: data?.dynamicIndexPattern, diff --git a/x-pack/plugins/apm/server/index.test.ts b/x-pack/plugins/apm/server/index.test.ts new file mode 100644 index 00000000000000..006a21b5974587 --- /dev/null +++ b/x-pack/plugins/apm/server/index.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { APMOSSConfig } from 'src/plugins/apm_oss/server'; +import { APMXPackConfig } from '.'; +import { mergeConfigs } from './index'; + +describe('mergeConfigs', () => { + it('merges the configs', () => { + const apmOssConfig = { + transactionIndices: 'apm-*-transaction-*', + spanIndices: 'apm-*-span-*', + errorIndices: 'apm-*-error-*', + metricsIndices: 'apm-*-metric-*', + indexPattern: 'apm-*', + } as APMOSSConfig; + + const apmConfig = { + ui: { enabled: false }, + enabled: true, + metricsInterval: 2000, + } as APMXPackConfig; + + expect(mergeConfigs(apmOssConfig, apmConfig)).toEqual({ + 'apm_oss.errorIndices': 'apm-*-error-*', + 'apm_oss.indexPattern': 'apm-*', + 'apm_oss.metricsIndices': 'apm-*-metric-*', + 'apm_oss.spanIndices': 'apm-*-span-*', + 'apm_oss.transactionIndices': 'apm-*-transaction-*', + 'xpack.apm.metricsInterval': 2000, + 'xpack.apm.ui.enabled': false, + }); + }); + + it('adds fleet indices', () => { + const apmOssConfig = { + transactionIndices: 'apm-*-transaction-*', + spanIndices: 'apm-*-span-*', + errorIndices: 'apm-*-error-*', + metricsIndices: 'apm-*-metric-*', + fleetMode: true, + } as APMOSSConfig; + + const apmConfig = { ui: {} } as APMXPackConfig; + + expect(mergeConfigs(apmOssConfig, apmConfig)).toEqual({ + 'apm_oss.errorIndices': 'logs-apm*,apm-*-error-*', + 'apm_oss.metricsIndices': 'metrics-apm*,apm-*-metric-*', + 'apm_oss.spanIndices': 'traces-apm*,apm-*-span-*', + 'apm_oss.transactionIndices': 'traces-apm*,apm-*-transaction-*', + }); + }); + + it('does not add fleet indices', () => { + const apmOssConfig = { + transactionIndices: 'apm-*-transaction-*', + spanIndices: 'apm-*-span-*', + errorIndices: 'apm-*-error-*', + metricsIndices: 'apm-*-metric-*', + fleetMode: false, + } as APMOSSConfig; + + const apmConfig = { ui: {} } as APMXPackConfig; + + expect(mergeConfigs(apmOssConfig, apmConfig)).toEqual({ + 'apm_oss.errorIndices': 'apm-*-error-*', + 'apm_oss.metricsIndices': 'apm-*-metric-*', + 'apm_oss.spanIndices': 'apm-*-span-*', + 'apm_oss.transactionIndices': 'apm-*-transaction-*', + }); + }); +}); diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index da3afd03513f05..52b5765a984d51 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -11,6 +11,7 @@ import { APMOSSConfig } from 'src/plugins/apm_oss/server'; import { APMPlugin } from './plugin'; import { SearchAggregatedTransactionSetting } from '../common/aggregated_transactions'; +// plugin config export const config = { exposeToBrowser: { serviceMapEnabled: true, @@ -50,20 +51,23 @@ export const config = { }; export type APMXPackConfig = TypeOf; +export type APMConfig = ReturnType; +// plugin config and ui indices settings export function mergeConfigs( apmOssConfig: APMOSSConfig, apmConfig: APMXPackConfig ) { - return { + const mergedConfig = { /* eslint-disable @typescript-eslint/naming-convention */ + // TODO: Remove all apm_oss options by 8.0 'apm_oss.transactionIndices': apmOssConfig.transactionIndices, 'apm_oss.spanIndices': apmOssConfig.spanIndices, 'apm_oss.errorIndices': apmOssConfig.errorIndices, 'apm_oss.metricsIndices': apmOssConfig.metricsIndices, 'apm_oss.sourcemapIndices': apmOssConfig.sourcemapIndices, 'apm_oss.onboardingIndices': apmOssConfig.onboardingIndices, - 'apm_oss.indexPattern': apmOssConfig.indexPattern, + 'apm_oss.indexPattern': apmOssConfig.indexPattern, // TODO: add data stream indices: traces-apm*,logs-apm*,metrics-apm*. Blocked by https://github.com/elastic/kibana/issues/87851 /* eslint-enable @typescript-eslint/naming-convention */ 'xpack.apm.serviceMapEnabled': apmConfig.serviceMapEnabled, 'xpack.apm.serviceMapFingerprintBucketSize': @@ -89,9 +93,27 @@ export function mergeConfigs( apmConfig.searchAggregatedTransactions, 'xpack.apm.metricsInterval': apmConfig.metricsInterval, }; -} -export type APMConfig = ReturnType; + if (apmOssConfig.fleetMode) { + mergedConfig[ + 'apm_oss.transactionIndices' + ] = `traces-apm*,${mergedConfig['apm_oss.transactionIndices']}`; + + mergedConfig[ + 'apm_oss.spanIndices' + ] = `traces-apm*,${mergedConfig['apm_oss.spanIndices']}`; + + mergedConfig[ + 'apm_oss.errorIndices' + ] = `logs-apm*,${mergedConfig['apm_oss.errorIndices']}`; + + mergedConfig[ + 'apm_oss.metricsIndices' + ] = `metrics-apm*,${mergedConfig['apm_oss.metricsIndices']}`; + } + + return mergedConfig; +} export const plugin = (initContext: PluginInitializerContext) => new APMPlugin(initContext); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index 8b2c83804b526e..b93513646fb9f7 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -41,8 +41,6 @@ type TypeOfProcessorEvent = { [ProcessorEvent.transaction]: Transaction; [ProcessorEvent.span]: Span; [ProcessorEvent.metric]: Metric; - [ProcessorEvent.onboarding]: unknown; - [ProcessorEvent.sourcemap]: unknown; }[T]; type ESSearchRequestOf = Omit< diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts new file mode 100644 index 00000000000000..4983d6d5159447 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts @@ -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. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ +import { APMEventESSearchRequest } from '.'; +import { ApmIndicesConfig } from '../../../settings/apm_indices/get_apm_indices'; +import { unpackProcessorEvents } from './unpack_processor_events'; + +describe('unpackProcessorEvents', () => { + let res: ReturnType; + beforeEach(() => { + const request = { + apm: { events: ['transaction', 'error'] }, + body: { query: { bool: { filter: [{ terms: { foo: 'bar' } }] } } }, + } as APMEventESSearchRequest; + + const indices = { + 'apm_oss.transactionIndices': 'my-apm-*-transaction-*', + 'apm_oss.metricsIndices': 'my-apm-*-metric-*', + 'apm_oss.errorIndices': 'my-apm-*-error-*', + 'apm_oss.spanIndices': 'my-apm-*-span-*', + 'apm_oss.onboardingIndices': 'my-apm-*-onboarding-', + 'apm_oss.sourcemapIndices': 'my-apm-*-sourcemap-*', + } as ApmIndicesConfig; + + res = unpackProcessorEvents(request, indices); + }); + + it('adds terms filter for apm events', () => { + expect(res.body.query.bool.filter).toContainEqual({ + terms: { 'processor.event': ['transaction', 'error'] }, + }); + }); + + it('merges queries', () => { + expect(res.body.query.bool.filter).toEqual([ + { terms: { foo: 'bar' } }, + { terms: { 'processor.event': ['transaction', 'error'] } }, + ]); + }); + + it('searches the specified indices', () => { + expect(res.index).toEqual(['my-apm-*-transaction-*', 'my-apm-*-error-*']); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts index e5e766069fa61c..eef9aff946ea7a 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts @@ -18,13 +18,11 @@ import { ApmIndicesName, } from '../../../settings/apm_indices/get_apm_indices'; -export const processorEventIndexMap: Record = { +const processorEventIndexMap: Record = { [ProcessorEvent.transaction]: 'apm_oss.transactionIndices', [ProcessorEvent.span]: 'apm_oss.spanIndices', [ProcessorEvent.metric]: 'apm_oss.metricsIndices', [ProcessorEvent.error]: 'apm_oss.errorIndices', - [ProcessorEvent.sourcemap]: 'apm_oss.sourcemapIndices', - [ProcessorEvent.onboarding]: 'apm_oss.onboardingIndices', }; export function unpackProcessorEvents( @@ -32,9 +30,7 @@ export function unpackProcessorEvents( indices: ApmIndicesConfig ) { const { apm, ...params } = request; - const events = uniq(apm.events); - const index = events.map((event) => indices[processorEventIndexMap[event]]); const withFilterForProcessorEvent: ESSearchRequest & { diff --git a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts index 28c7e97ea7d95e..d62386ed02835c 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts @@ -14,6 +14,7 @@ import { hasHistoricalAgentData } from '../services/get_services/has_historical_ import { Setup } from '../helpers/setup_request'; import { APMRequestHandlerContext } from '../../routes/typings'; import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client.js'; +import { getApmIndexPatternTitle } from './get_apm_index_pattern_title'; export async function createStaticIndexPattern( setup: Setup, @@ -35,7 +36,7 @@ export async function createStaticIndexPattern( } try { - const apmIndexPatternTitle = config['apm_oss.indexPattern']; + const apmIndexPatternTitle = getApmIndexPatternTitle(context); await savedObjectsClient.create( 'index-pattern', { diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts index 45cb9319ed0bc0..c427588f8d860d 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts @@ -10,11 +10,6 @@ import { IndexPatternsFetcher, FieldDescriptor, } from '../../../../../../src/plugins/data/server'; -import { ApmIndicesConfig } from '../settings/apm_indices/get_apm_indices'; -import { - ProcessorEvent, - UIProcessorEvent, -} from '../../../common/processor_event'; import { APMRequestHandlerContext } from '../../routes/typings'; interface IndexPatternTitleAndFields { @@ -30,15 +25,11 @@ const cache = new LRU({ // TODO: this is currently cached globally. In the future we might want to cache this per user export const getDynamicIndexPattern = async ({ context, - indices, - processorEvent, }: { context: APMRequestHandlerContext; - indices: ApmIndicesConfig; - processorEvent?: UIProcessorEvent; }) => { - const patternIndices = getPatternIndices(indices, processorEvent); - const indexPatternTitle = patternIndices.join(','); + const indexPatternTitle = context.config['apm_oss.indexPattern']; + const CACHE_KEY = `apm_dynamic_index_pattern_${indexPatternTitle}`; if (cache.has(CACHE_KEY)) { return cache.get(CACHE_KEY); @@ -54,7 +45,7 @@ export const getDynamicIndexPattern = async ({ // (would be a bad first time experience) try { const fields = await indexPatternsFetcher.getFieldsForWildcard({ - pattern: patternIndices, + pattern: indexPatternTitle, }); const indexPattern: IndexPatternTitleAndFields = { @@ -79,20 +70,3 @@ export const getDynamicIndexPattern = async ({ throw e; } }; - -function getPatternIndices( - indices: ApmIndicesConfig, - processorEvent?: UIProcessorEvent -) { - const indexNames = processorEvent - ? [processorEvent] - : [ProcessorEvent.transaction, ProcessorEvent.metric, ProcessorEvent.error]; - - const indicesMap = { - [ProcessorEvent.transaction]: indices['apm_oss.transactionIndices'], - [ProcessorEvent.metric]: indices['apm_oss.metricsIndices'], - [ProcessorEvent.error]: indices['apm_oss.errorIndices'], - }; - - return indexNames.map((name) => indicesMap[name as UIProcessorEvent]); -} diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index 239b909e1572c6..606ce870351564 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -6,7 +6,6 @@ Object { "events": Array [ "error", "metric", - "sourcemap", "transaction", ], }, diff --git a/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts b/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts index b1877533ca5148..8363e59f2522ed 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts @@ -18,7 +18,6 @@ export async function hasHistoricalAgentData(setup: Setup) { events: [ ProcessorEvent.error, ProcessorEvent.metric, - ProcessorEvent.sourcemap, ProcessorEvent.transaction, ], }, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 49fded8649c469..88049419145298 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -16,7 +16,8 @@ import { Plugin, PluginInitializerContext, } from 'src/core/server'; -import { APMConfig, APMXPackConfig, mergeConfigs } from '.'; +import { APMConfig, APMXPackConfig } from '.'; +import { mergeConfigs } from './index'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; diff --git a/x-pack/plugins/apm/server/routes/index_pattern.ts b/x-pack/plugins/apm/server/routes/index_pattern.ts index 5f42a33533c4f1..7f2b45072454fe 100644 --- a/x-pack/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/plugins/apm/server/routes/index_pattern.ts @@ -5,15 +5,12 @@ * 2.0. */ -import * as t from 'io-ts'; import { createStaticIndexPattern } from '../lib/index_pattern/create_static_index_pattern'; import { createRoute } from './create_route'; import { setupRequest } from '../lib/helpers/setup_request'; import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; import { getApmIndexPatternTitle } from '../lib/index_pattern/get_apm_index_pattern_title'; import { getDynamicIndexPattern } from '../lib/index_pattern/get_dynamic_index_pattern'; -import { getApmIndices } from '../lib/settings/apm_indices/get_apm_indices'; -import { UIProcessorEvent } from '../../common/processor_event'; export const staticIndexPatternRoute = createRoute((core) => ({ endpoint: 'POST /api/apm/index_pattern/static', @@ -31,32 +28,9 @@ export const staticIndexPatternRoute = createRoute((core) => ({ export const dynamicIndexPatternRoute = createRoute({ endpoint: 'GET /api/apm/index_pattern/dynamic', - params: t.partial({ - query: t.partial({ - processorEvent: t.union([ - t.literal('transaction'), - t.literal('metric'), - t.literal('error'), - ]), - }), - }), options: { tags: ['access:apm'] }, handler: async ({ context }) => { - const indices = await getApmIndices({ - config: context.config, - savedObjectsClient: context.core.savedObjects.client, - }); - - const processorEvent = context.params.query.processorEvent as - | UIProcessorEvent - | undefined; - - const dynamicIndexPattern = await getDynamicIndexPattern({ - context, - indices, - processorEvent, - }); - + const dynamicIndexPattern = await getDynamicIndexPattern({ context }); return { dynamicIndexPattern }; }, }); From 4834de661ebdcd425d635306b7fba2d6e2c5fb13 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Thu, 11 Feb 2021 10:36:07 +0000 Subject: [PATCH 12/24] [Alerting][Docs] adds documentation on NTP based synchronization (#90747) Adds docs on usage of NTP to sync nodes in a prod setting for alerting. --- .../alerting/alerting-production-considerations.asciidoc | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/user/alerting/alerting-production-considerations.asciidoc b/docs/user/alerting/alerting-production-considerations.asciidoc index 3a68e81879e249..cc7adc87b150ef 100644 --- a/docs/user/alerting/alerting-production-considerations.asciidoc +++ b/docs/user/alerting/alerting-production-considerations.asciidoc @@ -27,4 +27,9 @@ Because by default tasks are polled at 3 second intervals and only 10 tasks can For details on the settings that can influence the performance and throughput of Task Manager, see {task-manager-settings}. -============================================== \ No newline at end of file +============================================== + +[float] +=== Deployment considerations + +{es} and {kib} instances use the system clock to determine the current time. To ensure schedules are triggered when expected, you should synchronize the clocks of all nodes in the cluster using a time service such as http://www.ntp.org/[Network Time Protocol]. \ No newline at end of file From 01b3d0759023b094a7309fa1fde37a8677dcc7ab Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 11 Feb 2021 12:05:17 +0100 Subject: [PATCH 13/24] fix Lens unit test (#91106) --- .../indexpattern_datasource/operations/operations.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 78e84ce518ab57..8c5dee8bbb28f8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -86,12 +86,12 @@ describe('getOperationTypesForField', () => { ).toEqual([ 'range', 'terms', + 'median', 'avg', 'sum', 'min', 'max', 'cardinality', - 'median', 'percentile', 'last_value', ]); @@ -109,7 +109,7 @@ describe('getOperationTypesForField', () => { }, (op) => !op.isBucketed ) - ).toEqual(['avg', 'sum', 'min', 'max', 'cardinality', 'median', 'percentile', 'last_value']); + ).toEqual(['median', 'avg', 'sum', 'min', 'max', 'cardinality', 'percentile', 'last_value']); }); it('should return operations on dates', () => { From a9f2c9167337a8c2e07ae4f07129976a61d297c5 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 11 Feb 2021 13:08:39 +0200 Subject: [PATCH 14/24] [Security Solution][Case] ServiceNow ITSM: Add category & subcategory fields (#90547) --- x-pack/plugins/actions/README.md | 2 + .../servicenow/api.test.ts | 20 +++ .../builtin_action_types/servicenow/mocks.ts | 2 + .../builtin_action_types/servicenow/schema.ts | 6 +- .../case/common/api/connectors/jira.ts | 1 + .../case/common/api/connectors/resilient.ts | 1 + .../common/api/connectors/servicenow_itsm.ts | 3 + .../common/api/connectors/servicenow_sir.ts | 1 + .../case/server/connectors/case/index.test.ts | 24 ++- .../case/server/connectors/case/schema.ts | 2 + .../connectors/servicenow/itsm_formatter.ts | 4 +- .../servicenow/itsm_formmater.test.ts | 12 +- .../security_solution/cypress/objects/case.ts | 12 ++ .../cypress/tasks/create_new_case.ts | 2 +- .../cases/components/connectors/mock.ts | 12 ++ .../connectors/servicenow/helpers.ts | 12 ++ .../servicenow_itsm_case_fields.test.tsx | 67 +++++++- .../servicenow_itsm_case_fields.tsx | 122 ++++++++++++--- .../servicenow_sir_case_fields.test.tsx | 25 ++- .../servicenow/servicenow_sir_case_fields.tsx | 20 +-- .../components/connectors/servicenow/types.ts | 3 - .../components/create/connector.test.tsx | 8 +- .../components/create/form_context.test.tsx | 144 +++++++++++++++++- .../public/cases/components/create/mock.ts | 6 + .../public/cases/containers/configure/mock.ts | 9 ++ .../servicenow/helpers.ts | 12 ++ .../servicenow_itsm_params.test.tsx | 106 +++++++++---- .../servicenow/servicenow_itsm_params.tsx | 91 +++++++++-- .../servicenow/servicenow_sir_params.tsx | 16 +- .../builtin_action_types/servicenow/types.ts | 2 - .../uptime/public/state/api/alert_actions.ts | 2 + .../server/servicenow_simulation.ts | 2 + .../builtin_action_types/servicenow.ts | 2 + .../basic/tests/cases/push_case.ts | 32 +++- .../user_actions/get_all_user_actions.ts | 8 +- .../basic/tests/connectors/case.ts | 2 + 36 files changed, 666 insertions(+), 129 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/helpers.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 1d50bc7e058070..9d48e618b76dce 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -595,6 +595,8 @@ The following table describes the properties of the `incident` object. | severity | The name of the severity in ServiceNow. | string _(optional)_ | | urgency | The name of the urgency in ServiceNow. | string _(optional)_ | | impact | The name of the impact in ServiceNow. | string _(optional)_ | +| category | The name of the category in ServiceNow. | string _(optional)_ | +| subcategory | The name of the subcategory in ServiceNow. | string _(optional)_ | #### `subActionParams (getFields)` diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 662b1ce46a07b6..8d24e48d4d5150 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -88,6 +88,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', caller_id: 'elastic', description: 'Incident description', short_description: 'Incident title', @@ -111,6 +113,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', comments: 'A comment', description: 'Incident description', short_description: 'Incident title', @@ -123,6 +127,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', comments: 'Another comment', description: 'Incident description', short_description: 'Incident title', @@ -146,6 +152,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', @@ -158,6 +166,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', work_notes: 'Another comment', description: 'Incident description', short_description: 'Incident title', @@ -229,6 +239,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', description: 'Incident description', short_description: 'Incident title', }, @@ -251,6 +263,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', description: 'Incident description', short_description: 'Incident title', }, @@ -262,6 +276,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', comments: 'A comment', description: 'Incident description', short_description: 'Incident title', @@ -285,6 +301,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', description: 'Incident description', short_description: 'Incident title', }, @@ -296,6 +314,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 8a689bffb34089..909200472be332 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -112,6 +112,8 @@ const executorParams: ExecutorSubActionPushParams = { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', }, comments: [ { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index b89d53ee2c66ed..59b0803d189cdd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -45,6 +45,8 @@ const CommonAttributes = { short_description: schema.string(), description: schema.nullable(schema.string()), externalId: schema.nullable(schema.string()), + category: schema.nullable(schema.string()), + subcategory: schema.nullable(schema.string()), }; // Schema for ServiceNow Incident Management (ITSM) @@ -62,13 +64,11 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({ incident: schema.object({ ...CommonAttributes, - category: schema.nullable(schema.string()), dest_ip: schema.nullable(schema.string()), malware_hash: schema.nullable(schema.string()), malware_url: schema.nullable(schema.string()), - priority: schema.nullable(schema.string()), source_ip: schema.nullable(schema.string()), - subcategory: schema.nullable(schema.string()), + priority: schema.nullable(schema.string()), }), comments: CommentsSchema, }); diff --git a/x-pack/plugins/case/common/api/connectors/jira.ts b/x-pack/plugins/case/common/api/connectors/jira.ts index 15a6768b075616..d61f4ba91575eb 100644 --- a/x-pack/plugins/case/common/api/connectors/jira.ts +++ b/x-pack/plugins/case/common/api/connectors/jira.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; +// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts export const JiraFieldsRT = rt.type({ issueType: rt.union([rt.string, rt.null]), priority: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/connectors/resilient.ts b/x-pack/plugins/case/common/api/connectors/resilient.ts index d19aa5b21fb52c..dc59588d1e6eda 100644 --- a/x-pack/plugins/case/common/api/connectors/resilient.ts +++ b/x-pack/plugins/case/common/api/connectors/resilient.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; +// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts export const ResilientFieldsRT = rt.type({ incidentTypes: rt.union([rt.array(rt.string), rt.null]), severityCode: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts b/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts index 2e86a26971aaaa..9eedbcb44907ad 100644 --- a/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts +++ b/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts @@ -7,10 +7,13 @@ import * as rt from 'io-ts'; +// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts export const ServiceNowITSMFieldsRT = rt.type({ impact: rt.union([rt.string, rt.null]), severity: rt.union([rt.string, rt.null]), urgency: rt.union([rt.string, rt.null]), + category: rt.union([rt.string, rt.null]), + subcategory: rt.union([rt.string, rt.null]), }); export type ServiceNowITSMFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts b/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts index 749abdea87437b..b8d33f259ade7e 100644 --- a/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts +++ b/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; +// New fields should also be added at: x-pack/plugins/case/server/connectors/case/schema.ts export const ServiceNowSIRFieldsRT = rt.type({ category: rt.union([rt.string, rt.null]), destIp: rt.union([rt.boolean, rt.null]), diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index 236927967d0c5b..4a025fd980fe20 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -153,6 +153,8 @@ describe('case connector', () => { impact: 'Medium', severity: 'Medium', urgency: 'Medium', + category: 'software', + subcategory: 'os', }, }, settings: { @@ -218,7 +220,13 @@ describe('case connector', () => { id: 'servicenow', name: 'Servicenow', type: '.servicenow', - fields: { impact: null, severity: null, urgency: null }, + fields: { + impact: null, + severity: null, + urgency: null, + category: null, + subcategory: null, + }, }, settings: { syncAlerts: true, @@ -293,6 +301,8 @@ describe('case connector', () => { impact: 'Medium', severity: 'Medium', urgency: 'Medium', + category: 'software', + subcategory: 'os', excess: null, }, }, @@ -470,6 +480,8 @@ describe('case connector', () => { impact: 'Medium', severity: 'Medium', urgency: 'Medium', + category: 'software', + subcategory: 'os', }, }, }, @@ -517,7 +529,13 @@ describe('case connector', () => { id: 'servicenow', name: 'Servicenow', type: '.servicenow', - fields: { impact: null, severity: null, urgency: null }, + fields: { + impact: null, + severity: null, + urgency: null, + category: null, + subcategory: null, + }, }, }, }); @@ -590,6 +608,8 @@ describe('case connector', () => { impact: 'Medium', severity: 'Medium', urgency: 'Medium', + category: 'software', + subcategory: 'os', excess: null, }, }, diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index ba82190367b12c..8d52a344308e1a 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -53,6 +53,8 @@ const ServiceNowFieldsSchema = schema.object({ impact: schema.nullable(schema.string()), severity: schema.nullable(schema.string()), urgency: schema.nullable(schema.string()), + category: schema.nullable(schema.string()), + subcategory: schema.nullable(schema.string()), }); const NoneFieldsSchema = schema.nullable(schema.object({})); diff --git a/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts b/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts index 60faa82a9e3fa3..b49eed6a4ad267 100644 --- a/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts +++ b/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts @@ -9,9 +9,9 @@ import { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../ import { ExternalServiceFormatter } from '../types'; const format: ExternalServiceFormatter['format'] = (theCase) => { - const { severity = null, urgency = null, impact = null } = + const { severity = null, urgency = null, impact = null, category = null, subcategory = null } = (theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {}; - return { severity, urgency, impact }; + return { severity, urgency, impact, category, subcategory }; }; export const serviceNowITSMExternalServiceFormatter: ExternalServiceFormatter = { diff --git a/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts b/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts index 033f184c7e751c..ea3a4e41e17b87 100644 --- a/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts +++ b/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts @@ -10,7 +10,9 @@ import { serviceNowITSMExternalServiceFormatter } from './itsm_formatter'; describe('ITSM formatter', () => { const theCase = { - connector: { fields: { severity: '2', urgency: '2', impact: '2' } }, + connector: { + fields: { severity: '2', urgency: '2', impact: '2', category: 'software', subcategory: 'os' }, + }, } as CaseResponse; it('it formats correctly', async () => { @@ -21,6 +23,12 @@ describe('ITSM formatter', () => { it('it formats correctly when fields do not exist ', async () => { const invalidFields = { connector: { fields: null } } as CaseResponse; const res = await serviceNowITSMExternalServiceFormatter.format(invalidFields, []); - expect(res).toEqual({ severity: null, urgency: null, impact: null }); + expect(res).toEqual({ + severity: null, + urgency: null, + impact: null, + category: null, + subcategory: null, + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/objects/case.ts b/x-pack/plugins/security_solution/cypress/objects/case.ts index 7a3ce2cb00dfa3..a0135431c65439 100644 --- a/x-pack/plugins/security_solution/cypress/objects/case.ts +++ b/x-pack/plugins/security_solution/cypress/objects/case.ts @@ -153,6 +153,18 @@ export const executeResponses = { value: 'inbound_ddos', element: 'subcategory', }, + { + dependent_value: '', + label: 'Software', + value: 'software', + element: 'category', + }, + { + dependent_value: 'software', + label: 'Operation System', + value: 'os', + element: 'subcategory', + }, ...['severity', 'urgency', 'impact', 'priority'] .map((element) => [ { diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts index 15dddd48f0aca5..e67cee4f38734e 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts @@ -92,6 +92,6 @@ export const fillIbmResilientConnectorOptions = ( ibmResilientConnector.incidentTypes.forEach((incidentType) => { cy.get(SELECT_INCIDENT_TYPE).type(`${incidentType}{enter}`, { force: true }); }); - cy.get(CONNECTOR_RESILIENT).click(); + cy.get(CONNECTOR_RESILIENT).click({ force: true }); cy.get(SELECT_SEVERITY).select(ibmResilientConnector.severity); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts index 04e7338025258e..f5429fa2396aa6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts @@ -58,6 +58,18 @@ export const choices = [ value: 'inbound_ddos', element: 'subcategory', }, + { + dependent_value: '', + label: 'Software', + value: 'software', + element: 'category', + }, + { + dependent_value: 'software', + label: 'Operation System', + value: 'os', + element: 'subcategory', + }, ...['severity', 'urgency', 'impact', 'priority'] .map((element) => [ { diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/helpers.ts new file mode 100644 index 00000000000000..314d2244911288 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/helpers.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 { EuiSelectOption } from '@elastic/eui'; +import { Choice } from './types'; + +export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => + choices.map((choice) => ({ value: choice.value, text: choice.label })); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index 555ed0dcbb1618..6e2bdec360fdf4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -20,12 +20,18 @@ jest.mock('../../../../common/lib/kibana'); jest.mock('./use_get_choices', () => ({ useGetChoices: (args: { onSuccess: () => void }) => { onChoicesSuccess = args.onSuccess; - return { isLoading: false, mockChoices }; + return { isLoading: false, choices: mockChoices }; }, })); describe('ServiceNowITSM Fields', () => { - const fields = { severity: '1', urgency: '2', impact: '3' }; + const fields = { + severity: '1', + urgency: '2', + impact: '3', + category: 'software', + subcategory: 'os', + }; const onChange = jest.fn(); beforeEach(() => { @@ -37,6 +43,8 @@ describe('ServiceNowITSM Fields', () => { expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); }); it('all params fields are rendered - isEdit: false', () => { @@ -58,6 +66,42 @@ describe('ServiceNowITSM Fields', () => { ); }); + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([ + { value: 'Priviledge Escalation', text: 'Priviledge Escalation' }, + { + value: 'Criminal activity/investigation', + text: 'Criminal activity/investigation', + }, + { value: 'Denial of Service', text: 'Denial of Service' }, + { + value: 'software', + text: 'Software', + }, + ]); + }); + + test('it transforms the subcategories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([ + { + text: 'Operation System', + value: 'os', + }, + ]); + }); + it('it transforms the options correctly', async () => { const wrapper = mount(); act(() => { @@ -81,7 +125,7 @@ describe('ServiceNowITSM Fields', () => { expect(onChange).toHaveBeenCalledWith(fields); - const testers = ['severity', 'urgency', 'impact']; + const testers = ['severity', 'urgency', 'impact', 'subcategory']; testers.forEach((subj) => test(`${subj.toUpperCase()}`, async () => { await waitFor(() => { @@ -99,5 +143,22 @@ describe('ServiceNowITSM Fields', () => { }); }) ); + + test('it should set subcategory to null when changing category', async () => { + await waitFor(() => { + const select = wrapper.find(EuiSelect).filter(`[data-test-subj="categorySelect"]`)!; + select.prop('onChange')!({ + target: { + value: 'network', + }, + } as React.ChangeEvent); + }); + wrapper.update(); + expect(onChange).toHaveBeenCalledWith({ + ...fields, + subcategory: null, + category: 'network', + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index e278492b57148a..1fe592cfdebc4f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -17,22 +17,39 @@ import { import { useKibana } from '../../../../common/lib/kibana'; import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; -import { Options, Choice } from './types'; +import { Fields, Choice } from './types'; +import { choicesToEuiOptions } from './helpers'; -const useGetChoicesFields = ['urgency', 'severity', 'impact']; -const defaultOptions: Options = { +const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; +const defaultFields: Fields = { urgency: [], severity: [], impact: [], + category: [], + subcategory: [], }; const ServiceNowITSMFieldsComponent: React.FunctionComponent< ConnectorFieldsProps > = ({ isEdit = true, fields, connector, onChange }) => { const init = useRef(true); - const { severity = null, urgency = null, impact = null } = fields ?? {}; + const { severity = null, urgency = null, impact = null, category = null, subcategory = null } = + fields ?? {}; const { http, notifications } = useKibana().services; - const [options, setOptions] = useState(defaultOptions); + const [choices, setChoices] = useState(defaultFields); + + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); + const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); + const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); + const impactOptions = useMemo(() => choicesToEuiOptions(choices.impact), [choices.impact]); + + const subcategoryOptions = useMemo( + () => + choicesToEuiOptions( + choices.subcategory.filter((choice) => choice.dependent_value === category) + ), + [choices.subcategory, category] + ); const listItems = useMemo( () => [ @@ -40,7 +57,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< ? [ { title: i18n.URGENCY, - description: options.urgency.find((option) => `${option.value}` === urgency)?.text, + description: urgencyOptions.find((option) => `${option.value}` === urgency)?.text, }, ] : []), @@ -48,7 +65,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< ? [ { title: i18n.SEVERITY, - description: options.severity.find((option) => `${option.value}` === severity)?.text, + description: severityOptions.find((option) => `${option.value}` === severity)?.text, }, ] : []), @@ -56,27 +73,53 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< ? [ { title: i18n.IMPACT, - description: options.impact.find((option) => `${option.value}` === impact)?.text, + description: impactOptions.find((option) => `${option.value}` === impact)?.text, + }, + ] + : []), + ...(category != null && category.length > 0 + ? [ + { + title: i18n.CATEGORY, + description: categoryOptions.find((option) => `${option.value}` === category)?.text, + }, + ] + : []), + ...(subcategory != null && subcategory.length > 0 + ? [ + { + title: i18n.SUBCATEGORY, + description: subcategoryOptions.find((option) => `${option.value}` === subcategory) + ?.text, }, ] : []), ], - [urgency, options.urgency, options.severity, options.impact, severity, impact] + [ + category, + categoryOptions, + impact, + impactOptions, + severity, + severityOptions, + subcategory, + subcategoryOptions, + urgency, + urgencyOptions, + ] ); - const onChoicesSuccess = (choices: Choice[]) => - setOptions( - choices.reduce( - (acc, choice) => ({ + const onChoicesSuccess = (values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ ...acc, - [choice.element]: [ - ...(acc[choice.element] != null ? acc[choice.element] : []), - { value: choice.value, text: choice.label }, - ], + [value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value], }), - defaultOptions + defaultFields ) ); + }; const { isLoading: isLoadingChoices } = useGetChoices({ http, @@ -100,17 +143,17 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< useEffect(() => { if (init.current) { init.current = false; - onChange({ urgency, severity, impact }); + onChange({ urgency, severity, impact, category, subcategory }); } - }, [impact, onChange, severity, urgency]); + }, [category, impact, onChange, severity, subcategory, urgency]); return isEdit ? ( -
+
+ + + + onChange({ ...fields, category: e.target.value, subcategory: null })} + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + +
) : ( { text: 'Criminal activity/investigation', }, { value: 'Denial of Service', text: 'Denial of Service' }, + { + text: 'Software', + value: 'software', + }, ]); }); @@ -176,7 +180,7 @@ describe('ServiceNowSIR Fields', () => { }) ); - const testers = ['priority', 'category', 'subcategory']; + const testers = ['priority', 'subcategory']; testers.forEach((subj) => test(`${subj.toUpperCase()}`, async () => { await waitFor(() => { @@ -194,5 +198,24 @@ describe('ServiceNowSIR Fields', () => { }); }) ); + + test('it should set subcategory to null when changing category', async () => { + const select = wrapper.find(EuiSelect).filter(`[data-test-subj="categorySelect"]`)!; + select.prop('onChange')!({ + target: { + value: 'network', + }, + } as React.ChangeEvent); + + wrapper.update(); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith({ + ...fields, + subcategory: null, + category: 'network', + }); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 96db43fe261ac5..68cb4f867b3349 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -6,14 +6,7 @@ */ import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { - EuiFormRow, - EuiSelect, - EuiFlexGroup, - EuiFlexItem, - EuiSelectOption, - EuiCheckbox, -} from '@elastic/eui'; +import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiCheckbox } from '@elastic/eui'; import { ConnectorTypes, @@ -24,6 +17,7 @@ import { ConnectorFieldsProps } from '../types'; import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; import { Choice, Fields } from './types'; +import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; @@ -34,9 +28,6 @@ const defaultFields: Fields = { priority: [], }; -const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => - choices.map((choice) => ({ value: choice.value, text: choice.label })); - const ServiceNowSIRFieldsComponent: React.FunctionComponent< ConnectorFieldsProps > = ({ isEdit = true, fields, connector, onChange }) => { @@ -179,7 +170,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< }, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]); return isEdit ? ( -
+
@@ -259,7 +250,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< isLoading={isLoadingChoices} disabled={isLoadingChoices} hasNoInitialSelection - onChange={(e) => onChangeCb('category', e.target.value)} + onChange={(e) => onChange({ ...fields, category: e.target.value, subcategory: null })} /> @@ -269,7 +260,8 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< fullWidth data-test-subj="subcategorySelect" options={subcategoryOptions} - value={subcategory ?? undefined} + // Needs an empty string instead of undefined to select the blank option when changing categories + value={subcategory ?? ''} isLoading={isLoadingChoices} disabled={isLoadingChoices} hasNoInitialSelection diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts index deceeed29482b5..fd1af62f7bb2ac 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { EuiSelectOption } from '@elastic/eui'; - export interface Choice { value: string; label: string; @@ -15,4 +13,3 @@ export interface Choice { } export type Fields = Record; -export type Options = Record; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx index 236c13e5afc08e..9c5a4a0784af19 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx @@ -96,10 +96,10 @@ describe('Connector', () => { ); }); - // await waitFor(() => { - // wrapper.update(); - // expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeTruthy(); - // }); + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy(); + }); }); it('it is loading when fetching connectors', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx index 87658a78ac6f7a..8236ab7b19d27d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx @@ -20,6 +20,7 @@ import { connectorsMock } from '../../containers/configure/mock'; import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; import { useGetSeverity } from '../connectors/resilient/use_get_severity'; import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetFieldsByIssueType } from '../connectors/jira/use_get_fields_by_issue_type'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { @@ -30,6 +31,7 @@ import { useGetSeverityResponse, useGetIssueTypesResponse, useGetFieldsByIssueTypeResponse, + useGetChoicesResponse, } from './mock'; import { FormContext } from './form_context'; import { CreateCaseForm } from './form'; @@ -49,6 +51,7 @@ jest.mock('../connectors/jira/use_get_issue_types'); jest.mock('../connectors/jira/use_get_fields_by_issue_type'); jest.mock('../connectors/jira/use_get_single_issue'); jest.mock('../connectors/jira/use_get_issues'); +jest.mock('../connectors/servicenow/use_get_choices'); const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; @@ -58,6 +61,7 @@ const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; +const useGetChoicesMock = useGetChoices as jest.Mock; const postCase = jest.fn(); const pushCaseToExternalService = jest.fn(); @@ -109,6 +113,7 @@ describe('Create case', () => { useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); (useGetTags as jest.Mock).mockImplementation(() => ({ tags: sampleTags, @@ -219,6 +224,8 @@ describe('Create case', () => { impact: null, severity: null, urgency: null, + category: null, + subcategory: null, }, id: 'servicenow-1', name: 'My Connector', @@ -399,7 +406,7 @@ describe('Create case', () => { }); }); - it(`it should submit and push to servicenow connector`, async () => { + it(`it should submit and push to servicenow itsm connector`, async () => { useConnectorsMock.mockReturnValue({ ...sampleConnectorData, connectors: connectorsMock, @@ -415,10 +422,14 @@ describe('Create case', () => { ); fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeFalsy(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-1"]`).simulate('click'); - expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeTruthy(); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy(); + }); ['severitySelect', 'urgencySelect', 'impactSelect'].forEach((subj) => { wrapper @@ -429,6 +440,20 @@ describe('Create case', () => { }); }); + wrapper + .find('select[data-test-subj="categorySelect"]') + .first() + .simulate('change', { + target: { value: 'software' }, + }); + + wrapper + .find('select[data-test-subj="subcategorySelect"]') + .first() + .simulate('change', { + target: { value: 'os' }, + }); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); await waitFor(() => { @@ -438,7 +463,13 @@ describe('Create case', () => { id: 'servicenow-1', name: 'My Connector', type: '.servicenow', - fields: { impact: '2', severity: '2', urgency: '2' }, + fields: { + impact: '2', + severity: '2', + urgency: '2', + category: 'software', + subcategory: 'os', + }, }, }); @@ -448,7 +479,110 @@ describe('Create case', () => { id: 'servicenow-1', name: 'My Connector', type: '.servicenow', - fields: { impact: '2', severity: '2', urgency: '2' }, + fields: { + impact: '2', + severity: '2', + urgency: '2', + category: 'software', + subcategory: 'os', + }, + }, + }); + + expect(onFormSubmitSuccess).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); + }); + + it(`it should submit and push to servicenow sir connector`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-sir"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-sir"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-sir"]`).exists()).toBeTruthy(); + }); + + wrapper + .find('[data-test-subj="destIpCheckbox"] input') + .first() + .simulate('change', { target: { checked: false } }); + + wrapper + .find('select[data-test-subj="prioritySelect"]') + .first() + .simulate('change', { + target: { value: '1' }, + }); + + wrapper + .find('select[data-test-subj="categorySelect"]') + .first() + .simulate('change', { + target: { value: 'Denial of Service' }, + }); + + wrapper + .find('select[data-test-subj="subcategorySelect"]') + .first() + .simulate('change', { + target: { value: '26' }, + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => { + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + id: 'servicenow-sir', + name: 'My Connector SIR', + type: '.servicenow-sir', + fields: { + destIp: false, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + priority: '1', + category: 'Denial of Service', + subcategory: '26', + }, + }, + }); + + expect(pushCaseToExternalService).toHaveBeenCalledWith({ + caseId: sampleId, + connector: { + id: 'servicenow-sir', + name: 'My Connector SIR', + type: '.servicenow-sir', + fields: { + destIp: false, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + priority: '1', + category: 'Denial of Service', + subcategory: '26', + }, }, }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts b/x-pack/plugins/security_solution/public/cases/components/create/mock.ts index 5044b859702fa7..909b49940e1899 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/components/create/mock.ts @@ -7,6 +7,7 @@ import { CasePostRequest } from '../../../../../case/common/api'; import { ConnectorTypes } from '../../../../../case/common/api/connectors'; +import { choices } from '../connectors/mock'; export const sampleTags = ['coke', 'pepsi']; export const sampleData: CasePostRequest = { @@ -93,3 +94,8 @@ export const useGetFieldsByIssueTypeResponse = { }, }, }; + +export const useGetChoicesResponse = { + isLoading: false, + choices, +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts index 283e55f3759c78..c4ae60c7d1a73d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts @@ -61,6 +61,15 @@ export const connectorsMock: ActionConnector[] = [ }, isPreconfigured: false, }, + { + id: 'servicenow-sir', + actionTypeId: '.servicenow-sir', + name: 'My Connector SIR', + config: { + apiUrl: 'https://instance1.service-now.com', + }, + isPreconfigured: false, + }, ]; export const actionTypesMock: ActionTypeConnector[] = [ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts new file mode 100644 index 00000000000000..314d2244911288 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.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 { EuiSelectOption } from '@elastic/eui'; +import { Choice } from './types'; + +export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => + choices.map((choice) => ({ value: choice.value, text: choice.label })); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index bfc32ef67e46f0..e864a8d3fd114f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -28,6 +28,8 @@ const actionParams = { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', externalId: null, }, comments: [], @@ -55,34 +57,48 @@ const defaultProps = { const useGetChoicesResponse = { isLoading: false, - choices: ['severity', 'urgency', 'impact'] - .map((element) => [ - { - dependent_value: '', - label: '1 - Critical', - value: '1', - element, - }, - { - dependent_value: '', - label: '2 - High', - value: '2', - element, - }, - { - dependent_value: '', - label: '3 - Moderate', - value: '3', - element, - }, - { - dependent_value: '', - label: '4 - Low', - value: '4', - element, - }, - ]) - .flat(), + choices: [ + { + dependent_value: '', + label: 'Software', + value: 'software', + element: 'category', + }, + { + dependent_value: 'software', + label: 'Operation System', + value: 'os', + element: 'subcategory', + }, + ...['severity', 'urgency', 'impact'] + .map((element) => [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element, + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element, + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element, + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element, + }, + ]) + .flat(), + ], }; describe('ServiceNowITSMParamsFields renders', () => { @@ -101,6 +117,8 @@ describe('ServiceNowITSMParamsFields renders', () => { expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy(); @@ -153,6 +171,36 @@ describe('ServiceNowITSMParamsFields renders', () => { }); }); + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoices(useGetChoicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([ + { + value: 'software', + text: 'Software', + }, + ]); + }); + + test('it transforms the subcategories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoices(useGetChoicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([ + { + text: 'Operation System', + value: 'os', + }, + ]); + }); + test('it transforms the options correctly', async () => { const wrapper = mount(); act(() => { @@ -179,6 +227,8 @@ describe('ServiceNowITSMParamsFields renders', () => { { dataTestSubj: '[data-test-subj="urgencySelect"]', key: 'urgency' }, { dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' }, { dataTestSubj: '[data-test-subj="impactSelect"]', key: 'impact' }, + { dataTestSubj: '[data-test-subj="categorySelect"]', key: 'category' }, + { dataTestSubj: '[data-test-subj="subcategorySelect"]', key: 'subcategory' }, ]; simpleFields.forEach((field) => diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index 3befa232e5b521..84326a7ae9be8b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -16,17 +16,22 @@ import { } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; -import { ServiceNowITSMActionParams, Choice, Options } from './types'; +import { ServiceNowITSMActionParams, Choice, Fields } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; import { useGetChoices } from './use_get_choices'; +import { choicesToEuiOptions } from './helpers'; + import * as i18n from './translations'; -const useGetChoicesFields = ['urgency', 'severity', 'impact']; -const defaultOptions: Options = { +const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; +const defaultFields: Fields = { + category: [], + subcategory: [], urgency: [], severity: [], impact: [], + priority: [], }; const ServiceNowParamsFields: React.FunctionComponent< @@ -48,7 +53,7 @@ const ServiceNowParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); - const [options, setOptions] = useState(defaultOptions); + const [choices, setChoices] = useState(defaultFields); const editSubActionProperty = useCallback( (key: string, value: any) => { @@ -73,19 +78,32 @@ const ServiceNowParamsFields: React.FunctionComponent< [editSubActionProperty] ); - const onChoicesSuccess = (choices: Choice[]) => - setOptions( - choices.reduce( - (acc, choice) => ({ + const onChoicesSuccess = useCallback((values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ ...acc, - [choice.element]: [ - ...(acc[choice.element] != null ? acc[choice.element] : []), - { value: choice.value, text: choice.label }, - ], + [value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value], }), - defaultOptions + defaultFields ) ); + }, []); + + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); + const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); + const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); + const impactOptions = useMemo(() => choicesToEuiOptions(choices.impact), [choices.impact]); + + const subcategoryOptions = useMemo( + () => + choicesToEuiOptions( + choices.subcategory.filter( + (subcategory) => subcategory.dependent_value === incident.category + ) + ), + [choices.subcategory, incident.category] + ); const { isLoading: isLoadingChoices } = useGetChoices({ http, @@ -140,7 +158,7 @@ const ServiceNowParamsFields: React.FunctionComponent< hasNoInitialSelection isLoading={isLoadingChoices} disabled={isLoadingChoices} - options={options.urgency} + options={urgencyOptions} value={incident.urgency ?? ''} onChange={(e) => editSubActionProperty('urgency', e.target.value)} /> @@ -155,7 +173,7 @@ const ServiceNowParamsFields: React.FunctionComponent< hasNoInitialSelection isLoading={isLoadingChoices} disabled={isLoadingChoices} - options={options.severity} + options={severityOptions} value={incident.severity ?? ''} onChange={(e) => editSubActionProperty('severity', e.target.value)} /> @@ -169,7 +187,7 @@ const ServiceNowParamsFields: React.FunctionComponent< hasNoInitialSelection isLoading={isLoadingChoices} disabled={isLoadingChoices} - options={options.impact} + options={impactOptions} value={incident.impact ?? ''} onChange={(e) => editSubActionProperty('impact', e.target.value)} /> @@ -177,6 +195,47 @@ const ServiceNowParamsFields: React.FunctionComponent< + + + + { + editAction( + 'subActionParams', + { + incident: { ...incident, category: e.target.value, subcategory: null }, + comments, + }, + index + ); + }} + /> + + + + + editSubActionProperty('subcategory', e.target.value)} + /> + + + + - choices.map((choice) => ({ value: choice.value, text: choice.label })); - const ServiceNowSIRParamsFields: React.FunctionComponent< ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { @@ -218,16 +215,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< disabled={isLoadingChoices} options={priorityOptions} value={incident.priority ?? undefined} - onChange={(e) => { - editAction( - 'subActionParams', - { - incident: { ...incident, priority: e.target.value }, - comments, - }, - index - ); - }} + onChange={(e) => editSubActionProperty('priority', e.target.value)} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts index 09f27c92e80820..f252f4648e670e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { EuiSelectOption } from '@elastic/eui'; import { UserConfiguredActionConnector } from '../../../../types'; import { ExecutorSubActionPushParamsITSM, @@ -45,4 +44,3 @@ export interface Choice { } export type Fields = Record; -export type Options = Record; diff --git a/x-pack/plugins/uptime/public/state/api/alert_actions.ts b/x-pack/plugins/uptime/public/state/api/alert_actions.ts index 3fce0499c45015..17b3354b666c48 100644 --- a/x-pack/plugins/uptime/public/state/api/alert_actions.ts +++ b/x-pack/plugins/uptime/public/state/api/alert_actions.ts @@ -119,6 +119,8 @@ function getServiceNowActionParams(): ServiceNowActionParams { impact: '2', severity: '2', urgency: '2', + category: null, + subcategory: null, externalId: null, }, comments: [], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts index e2cbd3628d5fa1..8b8eb469897873 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts @@ -30,6 +30,8 @@ export function initPlugin(router: IRouter, path: string) { severity: schema.string({ defaultValue: '1' }), urgency: schema.string({ defaultValue: '1' }), impact: schema.string({ defaultValue: '1' }), + category: schema.maybe(schema.string()), + subcategory: schema.maybe(schema.string()), }), }, }, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 2d49c409a18fc5..2d584f764e5e47 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -40,6 +40,8 @@ export default function servicenowTest({ getService }: FtrProviderContext) { severity: '1', short_description: 'a title', urgency: '1', + category: 'software', + subcategory: 'software', }, comments: [ { diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts index ef7c57b3b47499..735c079c7b850a 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts @@ -80,7 +80,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); @@ -143,7 +149,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); @@ -196,7 +208,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); @@ -268,7 +286,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index d83d87da1e7afe..1cbf79cb3326ce 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -359,7 +359,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index bb94c31c220d69..302c3a0423bed6 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -476,6 +476,8 @@ export default ({ getService }: FtrProviderContext): void => { impact: null, severity: null, urgency: null, + category: null, + subcategory: null, }, }, created_by: { From 35eef9c4d89b16ab87b19efb0fd3cdfd665d16ad Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Thu, 11 Feb 2021 12:19:07 +0100 Subject: [PATCH 15/24] [core] fix cleanup logic for rolling file tests (#90797) * fix cleanup for rolling file tests * do not swallow errors Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../integration_tests/rolling_file_appender.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts index 714485da1654ac..fb2a714adb687a 100644 --- a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts +++ b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts @@ -48,11 +48,10 @@ describe('RollingFileAppender', () => { }); afterEach(async () => { - try { - await rmdir(testDir); - } catch (e) { - /* trap */ + if (testDir) { + await rmdir(testDir, { recursive: true }); } + if (root) { await root.shutdown(); } From aa468c1d5691527e2fb2b880d1f45e3e538af015 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 11 Feb 2021 14:24:08 +0300 Subject: [PATCH 16/24] [Timelion] Communicate the index pattern to the dashboard (#90623) * [Timelion] Communicate the index pattern to the dashboard Closes #86418 * update types / limits.yml * Update timelion_vis_type.tsx * fix typo * remove extra await Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .eslintignore | 2 +- packages/kbn-optimizer/limits.yml | 2 +- .../{public => common}/_generated_/chain.js | 0 .../vis_type_timelion/common/parser.ts | 50 +++++++++++++++++++ .../timelion_expression_input_helpers.ts | 31 ++++++------ .../public/helpers/arg_value_suggestions.ts | 43 ++++------------ .../public/timelion_vis_type.tsx | 20 +++++++- .../server/handlers/lib/parse_sheet.js | 11 ++-- tasks/config/peg.js | 4 +- 9 files changed, 102 insertions(+), 61 deletions(-) rename src/plugins/vis_type_timelion/{public => common}/_generated_/chain.js (100%) create mode 100644 src/plugins/vis_type_timelion/common/parser.ts diff --git a/.eslintignore b/.eslintignore index 5513ad1320232c..ea8ab55ad77269 100644 --- a/.eslintignore +++ b/.eslintignore @@ -22,7 +22,7 @@ snapshots.js /src/core/lib/kbn_internal_native_observable /src/legacy/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/broken /src/plugins/data/common/es_query/kuery/ast/_generated_/** -/src/plugins/vis_type_timelion/public/_generated_/** +/src/plugins/vis_type_timelion/common/_generated_/** /x-pack/legacy/plugins/**/__tests__/fixtures/** /x-pack/plugins/apm/e2e/tmp/* /x-pack/plugins/canvas/canvas_plugin diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index dacf0bc94b79d3..6d81b39df71135 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -91,7 +91,7 @@ pageLoadAssetSize: visTypeMetric: 42790 visTypeTable: 94934 visTypeTagcloud: 37575 - visTypeTimelion: 51933 + visTypeTimelion: 68883 visTypeTimeseries: 155203 visTypeVega: 153573 visTypeVislib: 242838 diff --git a/src/plugins/vis_type_timelion/public/_generated_/chain.js b/src/plugins/vis_type_timelion/common/_generated_/chain.js similarity index 100% rename from src/plugins/vis_type_timelion/public/_generated_/chain.js rename to src/plugins/vis_type_timelion/common/_generated_/chain.js diff --git a/src/plugins/vis_type_timelion/common/parser.ts b/src/plugins/vis_type_timelion/common/parser.ts new file mode 100644 index 00000000000000..b6c16a6f7b4edb --- /dev/null +++ b/src/plugins/vis_type_timelion/common/parser.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +// @ts-ignore +import { parse } from './_generated_/chain'; + +export interface ExpressionLocation { + min: number; + max: number; +} + +interface ExpressionItem { + name: string; + function: string; + location: ExpressionLocation; + text: string; + type: string; +} + +export interface TimelionExpressionArgument extends ExpressionItem { + value: { + location: ExpressionLocation; + type: string; + value: string; + text: string; + }; +} + +export interface TimelionExpressionFunction extends ExpressionItem { + arguments: TimelionExpressionArgument[]; +} + +export interface TimelionExpressionChain { + chain: TimelionExpressionFunction[]; + type: 'chain'; +} + +export interface ParsedExpression { + args: TimelionExpressionArgument[]; + functions: TimelionExpressionFunction[]; + tree: TimelionExpressionChain[]; + variables: Record; +} + +export const parseTimelionExpression = (input: string): ParsedExpression => parse(input); diff --git a/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts b/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts index a428bc946364b0..8f62abf7fe9be5 100644 --- a/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts +++ b/src/plugins/vis_type_timelion/public/components/timelion_expression_input_helpers.ts @@ -6,16 +6,17 @@ * Side Public License, v 1. */ -import { get, startsWith } from 'lodash'; +import { startsWith } from 'lodash'; import { i18n } from '@kbn/i18n'; import { monaco } from '@kbn/monaco'; +import { + parseTimelionExpression, + ParsedExpression, + TimelionExpressionArgument, + ExpressionLocation, +} from '../../common/parser'; -import { Parser } from 'pegjs'; - -// @ts-ignore -import { parse } from '../_generated_/chain'; - -import { ArgValueSuggestions, FunctionArg, Location } from '../helpers/arg_value_suggestions'; +import { ArgValueSuggestions } from '../helpers/arg_value_suggestions'; import { ITimelionFunction, TimelionFunctionArgs } from '../../common/types'; export enum SUGGESTION_TYPE { @@ -24,13 +25,13 @@ export enum SUGGESTION_TYPE { FUNCTIONS = 'functions', } -function inLocation(cursorPosition: number, location: Location) { +function inLocation(cursorPosition: number, location: ExpressionLocation) { return cursorPosition >= location.min && cursorPosition <= location.max; } function getArgumentsHelp( functionHelp: ITimelionFunction | undefined, - functionArgs: FunctionArg[] = [] + functionArgs: TimelionExpressionArgument[] = [] ) { if (!functionHelp) { return []; @@ -45,14 +46,12 @@ function getArgumentsHelp( } async function extractSuggestionsFromParsedResult( - result: ReturnType, + result: ParsedExpression, cursorPosition: number, functionList: ITimelionFunction[], argValueSuggestions: ArgValueSuggestions ) { - const activeFunc = result.functions.find(({ location }: { location: Location }) => - inLocation(cursorPosition, location) - ); + const activeFunc = result.functions.find(({ location }) => inLocation(cursorPosition, location)); if (!activeFunc) { return; @@ -72,7 +71,7 @@ async function extractSuggestionsFromParsedResult( } // return argument value suggestions when cursor is inside argument value - const activeArg = activeFunc.arguments.find((argument: FunctionArg) => { + const activeArg = activeFunc.arguments.find((argument) => { return inLocation(cursorPosition, argument.location); }); if ( @@ -112,7 +111,7 @@ async function extractSuggestionsFromParsedResult( // return argument suggestions const argsHelp = getArgumentsHelp(functionHelp, activeFunc.arguments); const argumentSuggestions = argsHelp.filter((arg) => { - if (get(activeArg, 'type') === 'namedArg') { + if (activeArg?.type === 'namedArg') { return startsWith(arg.name, activeArg.name); } else if (activeArg) { return startsWith(arg.name, activeArg.text); @@ -129,7 +128,7 @@ export async function suggest( argValueSuggestions: ArgValueSuggestions ) { try { - const result = await parse(expression); + const result = parseTimelionExpression(expression); return await extractSuggestionsFromParsedResult( result, diff --git a/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts b/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts index 2fbf42f4be19be..0a989858706dfa 100644 --- a/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts +++ b/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts @@ -9,37 +9,19 @@ import { get } from 'lodash'; import { getIndexPatterns } from './plugin_services'; import { TimelionFunctionArgs } from '../../common/types'; +import { TimelionExpressionFunction, TimelionExpressionArgument } from '../../common/parser'; import { IndexPatternField, indexPatterns as indexPatternsUtils, KBN_FIELD_TYPES, } from '../../../data/public'; -export interface Location { - min: number; - max: number; -} - -export interface FunctionArg { - function: string; - location: Location; - name: string; - text: string; - type: string; - value: { - location: Location; - text: string; - type: string; - value: string; - }; -} - const isRuntimeField = (field: IndexPatternField) => Boolean(field.runtimeField); export function getArgValueSuggestions() { const indexPatterns = getIndexPatterns(); - async function getIndexPattern(functionArgs: FunctionArg[]) { + async function getIndexPattern(functionArgs: TimelionExpressionFunction[]) { const indexPatternArg = functionArgs.find(({ name }) => name === 'index'); if (!indexPatternArg) { // index argument not provided @@ -61,7 +43,7 @@ export function getArgValueSuggestions() { // Argument value suggestion handlers requiring custom client side code // Could not put with function definition since functions are defined on server - const customHandlers = { + const customHandlers: Record = { es: { async index(partial: string) { const search = partial ? `${partial}*` : '*'; @@ -71,7 +53,7 @@ export function getArgValueSuggestions() { name: title, })); }, - async metric(partial: string, functionArgs: FunctionArg[]) { + async metric(partial: string, functionArgs: TimelionExpressionFunction[]) { if (!partial || !partial.includes(':')) { return [ { name: 'avg:' }, @@ -101,7 +83,7 @@ export function getArgValueSuggestions() { ) .map((field) => ({ name: `${valueSplit[0]}:${field.name}`, help: field.type })); }, - async split(partial: string, functionArgs: FunctionArg[]) { + async split(partial: string, functionArgs: TimelionExpressionFunction[]) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { return []; @@ -125,7 +107,7 @@ export function getArgValueSuggestions() { ) .map((field) => ({ name: field.name, help: field.type })); }, - async timefield(partial: string, functionArgs: FunctionArg[]) { + async timefield(partial: string, functionArgs: TimelionExpressionFunction[]) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { return []; @@ -150,10 +132,7 @@ export function getArgValueSuggestions() { * @param {string} argName - user provided argument name * @return {boolean} true when dynamic suggestion handler provided for function argument */ - hasDynamicSuggestionsForArgument: ( - functionName: T, - argName: keyof typeof customHandlers[T] - ) => { + hasDynamicSuggestionsForArgument: (functionName: string, argName: string) => { return customHandlers[functionName] && customHandlers[functionName][argName]; }, @@ -164,10 +143,10 @@ export function getArgValueSuggestions() { * @param {string} partial - user provided argument value * @return {array} array of dynamic suggestions matching partial */ - getDynamicSuggestionsForArgument: async ( - functionName: T, - argName: keyof typeof customHandlers[T], - functionArgs: FunctionArg[], + getDynamicSuggestionsForArgument: async ( + functionName: string, + argName: string, + functionArgs: TimelionExpressionArgument[], partialInput = '' ) => { // @ts-ignore diff --git a/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx b/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx index b41bea96de302a..2f6f3dd58f61fc 100644 --- a/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx +++ b/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx @@ -13,8 +13,11 @@ import { DefaultEditorSize } from '../../vis_default_editor/public'; import { TimelionOptionsProps } from './timelion_options'; import { TimelionVisDependencies } from './plugin'; import { toExpressionAst } from './to_ast'; +import { getIndexPatterns } from './helpers/plugin_services'; -import { VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; +import { parseTimelionExpression } from '../common/parser'; + +import { VIS_EVENT_TO_TRIGGER, VisParams } from '../../visualizations/public'; const TimelionOptions = lazy(() => import('./timelion_options')); @@ -47,6 +50,21 @@ export function getTimelionVisDefinition(dependencies: TimelionVisDependencies) getSupportedTriggers: () => { return [VIS_EVENT_TO_TRIGGER.applyFilter]; }, + getUsedIndexPattern: (params: VisParams) => { + try { + const args = parseTimelionExpression(params.expression)?.args ?? []; + const indexArg = args.find( + ({ type, name, function: fn }) => type === 'namedArg' && fn === 'es' && name === 'index' + ); + + if (indexArg?.value.text) { + return getIndexPatterns().find(indexArg.value.text); + } + } catch { + // timelion expression is invalid + } + return []; + }, options: { showIndexSelection: false, showQueryBar: false, diff --git a/src/plugins/vis_type_timelion/server/handlers/lib/parse_sheet.js b/src/plugins/vis_type_timelion/server/handlers/lib/parse_sheet.js index 67bde9d7e6daa8..d1965c422e5092 100644 --- a/src/plugins/vis_type_timelion/server/handlers/lib/parse_sheet.js +++ b/src/plugins/vis_type_timelion/server/handlers/lib/parse_sheet.js @@ -7,17 +7,12 @@ */ import { i18n } from '@kbn/i18n'; -import fs from 'fs'; -import path from 'path'; -import _ from 'lodash'; -const grammar = fs.readFileSync(path.resolve(__dirname, '../../../common/chain.peg'), 'utf8'); -import PEG from 'pegjs'; -const Parser = PEG.generate(grammar); +import { parseTimelionExpression } from '../../../common/parser'; export default function parseSheet(sheet) { - return _.map(sheet, function (plot) { + return sheet.map(function (plot) { try { - return Parser.parse(plot).tree; + return parseTimelionExpression(plot).tree; } catch (e) { if (e.expected) { throw new Error( diff --git a/tasks/config/peg.js b/tasks/config/peg.js index 117af5909f23e4..09da1ed81c2229 100644 --- a/tasks/config/peg.js +++ b/tasks/config/peg.js @@ -15,7 +15,7 @@ module.exports = { }, }, timelion_chain: { - src: 'src/plugins/vis_type_timelion/public/chain.peg', - dest: 'src/plugins/vis_type_timelion/public/_generated_/chain.js', + src: 'src/plugins/vis_type_timelion/common/chain.peg', + dest: 'src/plugins/vis_type_timelion/common/_generated_/chain.js', }, }; From 9f38e3af34e2acae250d6e64a09e6aca309de665 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Thu, 11 Feb 2021 12:37:59 +0100 Subject: [PATCH 17/24] [Lens] Fix UI regression on toolbar buttons (#90953) --- .../__snapshots__/toolbar_button.test.tsx.snap | 6 +++--- .../kibana_react/public/toolbar_button/toolbar_button.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/plugins/kibana_react/public/toolbar_button/__snapshots__/toolbar_button.test.tsx.snap b/src/plugins/kibana_react/public/toolbar_button/__snapshots__/toolbar_button.test.tsx.snap index 294be46398e8a2..753dd8d6fe81fa 100644 --- a/src/plugins/kibana_react/public/toolbar_button/__snapshots__/toolbar_button.test.tsx.snap +++ b/src/plugins/kibana_react/public/toolbar_button/__snapshots__/toolbar_button.test.tsx.snap @@ -68,7 +68,7 @@ exports[`hasArrow is rendered 1`] = ` exports[`positions center is applied 1`] = ` = ({ [`kbnToolbarButton--${fontWeight}`, `kbnToolbarButton--${size}`], className ); + return ( Date: Thu, 11 Feb 2021 13:51:42 +0100 Subject: [PATCH 18/24] [ML] Transform functional tests - re-enable feature controls suite (#91095) This PR re-enables the transform feature controls test suite. --- .../apps/transform/feature_controls/transform_security.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts index b8d6b88e4ed9a5..46e0c01afcc38c 100644 --- a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts +++ b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts @@ -15,8 +15,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const appsMenu = getService('appsMenu'); const managementMenu = getService('managementMenu'); - // FLAKY: https://github.com/elastic/kibana/issues/90576 - describe.skip('security', () => { + describe('security', () => { before(async () => { await esArchiver.load('empty_kibana'); await PageObjects.security.forceLogout(); From 16eb41b9ccd9f7a4396be549afb00ed65adb8807 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 11 Feb 2021 14:16:41 +0100 Subject: [PATCH 19/24] pass through correct type (#90574) --- .../data/common/search/aggs/agg_type.ts | 9 ++++- .../common/search/aggs/metrics/cardinality.ts | 1 + .../search/tabify/response_writer.test.ts | 36 ++++++++++++++----- .../common/search/tabify/response_writer.ts | 3 +- src/plugins/data/public/public.api.md | 1 + src/plugins/data/server/server.api.md | 1 + 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts index 7931ce1f595770..4583be17478e36 100644 --- a/src/plugins/data/common/search/aggs/agg_type.ts +++ b/src/plugins/data/common/search/aggs/agg_type.ts @@ -10,7 +10,7 @@ import { constant, noop, identity } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ISearchSource } from 'src/plugins/data/public'; -import { SerializedFieldFormat } from 'src/plugins/expressions/common'; +import { DatatableColumnType, SerializedFieldFormat } from 'src/plugins/expressions/common'; import type { RequestAdapter } from 'src/plugins/inspector/common'; import { initParams } from './agg_params'; @@ -33,6 +33,7 @@ export interface AggTypeConfig< ordered?: any; hasNoDsl?: boolean; params?: Array>; + valueType?: DatatableColumnType; getRequestAggs?: ((aggConfig: TAggConfig) => TAggConfig[]) | (() => TAggConfig[] | void); getResponseAggs?: ((aggConfig: TAggConfig) => TAggConfig[]) | (() => TAggConfig[] | void); customLabels?: boolean; @@ -91,6 +92,11 @@ export class AggType< * @type {string} */ title: string; + /** + * The type the values produced by this agg will have in the final data table. + * If not specified, the type of the field is used. + */ + valueType?: DatatableColumnType; /** * a function that will be called when this aggType is assigned to * an aggConfig, and that aggConfig is being rendered (in a form, chart, etc.). @@ -222,6 +228,7 @@ export class AggType< this.dslName = config.dslName || config.name; this.expressionName = config.expressionName; this.title = config.title; + this.valueType = config.valueType; this.makeLabel = config.makeLabel || constant(this.name); this.ordered = config.ordered; this.hasNoDsl = !!config.hasNoDsl; diff --git a/src/plugins/data/common/search/aggs/metrics/cardinality.ts b/src/plugins/data/common/search/aggs/metrics/cardinality.ts index 38dd5893eb8adf..5a18924902fc38 100644 --- a/src/plugins/data/common/search/aggs/metrics/cardinality.ts +++ b/src/plugins/data/common/search/aggs/metrics/cardinality.ts @@ -24,6 +24,7 @@ export interface AggParamsCardinality extends BaseAggParams { export const getCardinalityMetricAgg = () => new MetricAggType({ name: METRIC_TYPES.CARDINALITY, + valueType: 'number', expressionName: aggCardinalityFnName, title: uniqueCountTitle, makeLabel(aggConfig: IMetricAggConfig) { diff --git a/src/plugins/data/common/search/tabify/response_writer.test.ts b/src/plugins/data/common/search/tabify/response_writer.test.ts index 6cec6d431ab705..603ccc0f493c7a 100644 --- a/src/plugins/data/common/search/tabify/response_writer.test.ts +++ b/src/plugins/data/common/search/tabify/response_writer.test.ts @@ -7,7 +7,7 @@ */ import { TabbedAggResponseWriter } from './response_writer'; -import { AggConfigs, BUCKET_TYPES } from '../aggs'; +import { AggConfigs, BUCKET_TYPES, METRIC_TYPES } from '../aggs'; import { mockAggTypesRegistry } from '../aggs/test_helpers'; import { TabbedResponseWriterOptions } from './types'; @@ -23,7 +23,12 @@ describe('TabbedAggResponseWriter class', () => { field: 'geo.src', }, }, - { type: 'count' }, + { + type: METRIC_TYPES.CARDINALITY, + params: { + field: 'machine.os.raw', + }, + }, ]; const twoSplitsAggConfig = [ @@ -39,7 +44,12 @@ describe('TabbedAggResponseWriter class', () => { field: 'machine.os.raw', }, }, - { type: 'count' }, + { + type: METRIC_TYPES.CARDINALITY, + params: { + field: 'machine.os.raw', + }, + }, ]; const createResponseWritter = (aggs: any[] = [], opts?: Partial) => { @@ -174,19 +184,23 @@ describe('TabbedAggResponseWriter class', () => { }); expect(response.columns[1]).toHaveProperty('id', 'col-1-2'); - expect(response.columns[1]).toHaveProperty('name', 'Count'); + expect(response.columns[1]).toHaveProperty('name', 'Unique count of machine.os.raw'); expect(response.columns[1]).toHaveProperty('meta', { index: 'logstash-*', + field: 'machine.os.raw', params: { id: 'number', }, source: 'esaggs', sourceParams: { + appliedTimeRange: undefined, enabled: true, id: '2', indexPatternId: '1234', - params: {}, - type: 'count', + params: { + field: 'machine.os.raw', + }, + type: 'cardinality', }, type: 'number', }); @@ -231,19 +245,23 @@ describe('TabbedAggResponseWriter class', () => { }); expect(response.columns[1]).toHaveProperty('id', 'col-1-2'); - expect(response.columns[1]).toHaveProperty('name', 'Count'); + expect(response.columns[1]).toHaveProperty('name', 'Unique count of machine.os.raw'); expect(response.columns[1]).toHaveProperty('meta', { index: 'logstash-*', + field: 'machine.os.raw', params: { id: 'number', }, source: 'esaggs', sourceParams: { + appliedTimeRange: undefined, enabled: true, id: '2', indexPatternId: '1234', - params: {}, - type: 'count', + params: { + field: 'machine.os.raw', + }, + type: 'cardinality', }, type: 'number', }); diff --git a/src/plugins/data/common/search/tabify/response_writer.ts b/src/plugins/data/common/search/tabify/response_writer.ts index 1c312e5cd2200a..a0ba07598e53a9 100644 --- a/src/plugins/data/common/search/tabify/response_writer.ts +++ b/src/plugins/data/common/search/tabify/response_writer.ts @@ -73,7 +73,8 @@ export class TabbedAggResponseWriter { id: column.id, name: column.name, meta: { - type: column.aggConfig.params.field?.type || 'number', + type: + column.aggConfig.type.valueType || column.aggConfig.params.field?.type || 'number', field: column.aggConfig.params.field?.name, index: column.aggConfig.getIndexPattern()?.title, params: column.aggConfig.toSerializedFieldFormat(), diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 95c849ce74248b..4668ce2208610d 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -24,6 +24,7 @@ import * as CSS from 'csstype'; import { Datatable as Datatable_2 } from 'src/plugins/expressions'; import { Datatable as Datatable_3 } from 'src/plugins/expressions/common'; import { DatatableColumn as DatatableColumn_2 } from 'src/plugins/expressions'; +import { DatatableColumnType } from 'src/plugins/expressions/common'; import { DetailedPeerCertificate } from 'tls'; import { Ensure } from '@kbn/utility-types'; import { EnvironmentMode } from '@kbn/config'; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 3b1440f211bfed..7cf7e7e2c8d5e8 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -18,6 +18,7 @@ import { CoreStart as CoreStart_2 } from 'src/core/server'; import { Datatable } from 'src/plugins/expressions'; import { Datatable as Datatable_2 } from 'src/plugins/expressions/common'; import { DatatableColumn } from 'src/plugins/expressions'; +import { DatatableColumnType } from 'src/plugins/expressions/common'; import { Duration } from 'moment'; import { ElasticsearchClient } from 'src/core/server'; import { ElasticsearchClient as ElasticsearchClient_2 } from 'kibana/server'; From 57e619be071cda6c086544bcb8ebabb1c3a30ebb Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Thu, 11 Feb 2021 08:38:30 -0500 Subject: [PATCH 20/24] [App Search] Migrate Create Engine view (#89816) * New CreateEngine view component * Add CreateEngine to index router * Add Layout-level components for CreateEngine * Static create engine view * Add new POST route for engines API endpoint * Logic for Create Engine view WIP tests failing * Fix enterpriseSearchRequestHandler path * Use setQueuedSuccessMessage after engine has been created * Use exact path for CREATE_ENGINES_PATH (but EngineRouter logic is still firing??) * Add TODO note * Put CreateEngine inside the common App Search Layout * Fix CreateEngineLogic jest tests * Move create engine view to /create_engine from /engines/new * Add Create an Engine button to Engines Overview * Missing FlashMessages on EngineOverview * Fix test for CreateEngine route * Fix strong'd text in santized name note * Use local constant for Supported Languages * Disable submit button when name is empty * Bad conflict fix * Lint nits * Improve CreateEngineLogic tests * Improve EngineOverview tests * Disable EnginesOverview header responsiveness * Moving CreateEngine route * create_engine/CreateEngine -> engine_creation/EngineCreation * Use static values for tests * Fixing constants, better casing, better ID names, i18ning dropdown labels * Removing unused imports * Fix EngineCreation tests * Fix Engines EmptyState tests * Fix EnginesOverview tests * Lint fixes * Reset mocks after tests * Update MockRouter properties * Revert newline change * Lint fix --- .../components/engine_creation/constants.ts | 215 ++++++++++++++++++ .../engine_creation/engine_creation.test.tsx | 119 ++++++++++ .../engine_creation/engine_creation.tsx | 130 +++++++++++ .../engine_creation_logic.test.ts | 122 ++++++++++ .../engine_creation/engine_creation_logic.ts | 81 +++++++ .../components/engine_creation/index.ts | 8 + .../engine_overview/engine_overview_empty.tsx | 2 + .../engine_overview_metrics.tsx | 3 + .../engines/components/empty_state.test.tsx | 30 ++- .../engines/components/empty_state.tsx | 31 +-- .../components/engines/constants.ts | 7 + .../engines/engines_overview.test.tsx | 8 + .../components/engines/engines_overview.tsx | 29 ++- .../applications/app_search/index.test.tsx | 19 +- .../public/applications/app_search/index.tsx | 12 +- .../public/applications/app_search/routes.ts | 2 +- .../server/routes/app_search/engines.test.ts | 41 ++++ .../server/routes/app_search/engines.ts | 15 ++ 18 files changed, 839 insertions(+), 35 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/constants.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/constants.ts new file mode 100644 index 00000000000000..0647eeba787861 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/constants.ts @@ -0,0 +1,215 @@ +/* + * Copyright 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 DEFAULT_LANGUAGE = 'Universal'; + +export const ENGINE_CREATION_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.title', + { + defaultMessage: 'Create an engine', + } +); + +export const ENGINE_CREATION_FORM_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.title', + { + defaultMessage: 'Name your engine', + } +); + +export const ENGINE_CREATION_FORM_ENGINE_NAME_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.label', + { + defaultMessage: 'Engine name', + } +); + +export const ALLOWED_CHARS_NOTE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.allowedCharactersHelpText', + { + defaultMessage: 'Engine names can only contain lowercase letters, numbers, and hyphens', + } +); + +export const SANITIZED_NAME_NOTE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.sanitizedNameHelpText', + { + defaultMessage: 'Your engine will be named', + } +); + +export const ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.placeholder', + { + defaultMessage: 'i.e., my-search-engine', + } +); + +export const ENGINE_CREATION_FORM_ENGINE_LANGUAGE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineLanguage.label', + { + defaultMessage: 'Engine language', + } +); + +export const ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.submitButton.buttonLabel', + { + defaultMessage: 'Create engine', + } +); + +export const ENGINE_CREATION_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.successMessage', + { + defaultMessage: 'Successfully created engine.', + } +); + +export const SUPPORTED_LANGUAGES = [ + { + value: 'Universal', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.universalDropDownOptionLabel', + { + defaultMessage: 'Universal', + } + ), + }, + { + text: '—', + disabled: true, + }, + { + value: 'zh', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.chineseDropDownOptionLabel', + { + defaultMessage: 'Chinese', + } + ), + }, + { + value: 'da', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.danishDropDownOptionLabel', + { + defaultMessage: 'Danish', + } + ), + }, + { + value: 'nl', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.dutchDropDownOptionLabel', + { + defaultMessage: 'Dutch', + } + ), + }, + { + value: 'en', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.englishDropDownOptionLabel', + { + defaultMessage: 'English', + } + ), + }, + { + value: 'fr', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.frenchDropDownOptionLabel', + { + defaultMessage: 'French', + } + ), + }, + { + value: 'de', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.germanDropDownOptionLabel', + { + defaultMessage: 'German', + } + ), + }, + { + value: 'it', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.italianDropDownOptionLabel', + { + defaultMessage: 'Italian', + } + ), + }, + { + value: 'ja', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.japaneseDropDownOptionLabel', + { + defaultMessage: 'Japanese', + } + ), + }, + { + value: 'ko', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.koreanDropDownOptionLabel', + { + defaultMessage: 'Korean', + } + ), + }, + { + value: 'pt', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.portugueseDropDownOptionLabel', + { + defaultMessage: 'Portuguese', + } + ), + }, + { + value: 'pt-br', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.portugueseBrazilDropDownOptionLabel', + { + defaultMessage: 'Portuguese (Brazil)', + } + ), + }, + { + value: 'ru', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.russianDropDownOptionLabel', + { + defaultMessage: 'Russian', + } + ), + }, + { + value: 'es', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.spanishDropDownOptionLabel', + { + defaultMessage: 'Spanish', + } + ), + }, + { + value: 'th', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.thaiDropDownOptionLabel', + { + defaultMessage: 'Thai', + } + ), + }, +]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx new file mode 100644 index 00000000000000..cf30fac3c5f49f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.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 { setMockActions, setMockValues } from '../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EngineCreation } from './'; + +describe('EngineCreation', () => { + const DEFAULT_VALUES = { + name: '', + rawName: '', + language: 'Universal', + }; + + const MOCK_ACTIONS = { + setRawName: jest.fn(), + setLanguage: jest.fn(), + submitEngine: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(DEFAULT_VALUES); + setMockActions(MOCK_ACTIONS); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="EngineCreation"]')).toHaveLength(1); + }); + + it('EngineCreationForm calls submitEngine on form submit', () => { + const wrapper = shallow(); + const simulatedEvent = { + preventDefault: jest.fn(), + }; + wrapper.find('[data-test-subj="EngineCreationForm"]').simulate('submit', simulatedEvent); + + expect(MOCK_ACTIONS.submitEngine).toHaveBeenCalledTimes(1); + }); + + it('EngineCreationNameInput calls setRawName on change', () => { + const wrapper = shallow(); + const simulatedEvent = { + currentTarget: { value: 'new-raw-name' }, + }; + wrapper.find('[data-test-subj="EngineCreationNameInput"]').simulate('change', simulatedEvent); + + expect(MOCK_ACTIONS.setRawName).toHaveBeenCalledWith('new-raw-name'); + }); + + it('EngineCreationLanguageInput calls setLanguage on change', () => { + const wrapper = shallow(); + const simulatedEvent = { + currentTarget: { value: 'English' }, + }; + wrapper + .find('[data-test-subj="EngineCreationLanguageInput"]') + .simulate('change', simulatedEvent); + + expect(MOCK_ACTIONS.setLanguage).toHaveBeenCalledWith('English'); + }); + + describe('NewEngineSubmitButton', () => { + it('is disabled when name is empty', () => { + setMockValues({ ...DEFAULT_VALUES, name: '', rawName: '' }); + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="NewEngineSubmitButton"]').prop('disabled')).toEqual( + true + ); + }); + + it('is enabled when name has a value', () => { + setMockValues({ ...DEFAULT_VALUES, name: 'test', rawName: 'test' }); + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="NewEngineSubmitButton"]').prop('disabled')).toEqual( + false + ); + }); + }); + + describe('EngineCreationNameFormRow', () => { + it('renders sanitized name helptext when the raw name is being sanitized', () => { + setMockValues({ + ...DEFAULT_VALUES, + name: 'un-sanitized-name', + rawName: 'un-----sanitized-------name', + }); + const wrapper = shallow(); + const formRow = wrapper.find('[data-test-subj="EngineCreationNameFormRow"]').dive(); + + expect(formRow.contains('Your engine will be named')).toBeTruthy(); + }); + + it('renders allowed character helptext when rawName and sanitizedName match', () => { + setMockValues({ + ...DEFAULT_VALUES, + name: 'pre-sanitized-name', + rawName: 'pre-sanitized-name', + }); + const wrapper = shallow(); + const formRow = wrapper.find('[data-test-subj="EngineCreationNameFormRow"]').dive(); + + expect( + formRow.contains('Engine names can only contain lowercase letters, numbers, and hyphens') + ).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx new file mode 100644 index 00000000000000..497c00d1f91445 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiForm, + EuiFlexGroup, + EuiFormRow, + EuiFlexItem, + EuiFieldText, + EuiSelect, + EuiPageBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, + EuiText, + EuiTitle, + EuiButton, + EuiPanel, +} from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; + +import { + ALLOWED_CHARS_NOTE, + ENGINE_CREATION_FORM_ENGINE_LANGUAGE_LABEL, + ENGINE_CREATION_FORM_ENGINE_NAME_LABEL, + ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER, + ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL, + ENGINE_CREATION_FORM_TITLE, + ENGINE_CREATION_TITLE, + SANITIZED_NAME_NOTE, + SUPPORTED_LANGUAGES, +} from './constants'; +import { EngineCreationLogic } from './engine_creation_logic'; + +export const EngineCreation: React.FC = () => { + const { name, rawName, language } = useValues(EngineCreationLogic); + const { setLanguage, setRawName, submitEngine } = useActions(EngineCreationLogic); + + return ( +
+ + + + +

{ENGINE_CREATION_TITLE}

+
+
+
+ + + + +
{ + e.preventDefault(); + submitEngine(); + }} + > + + {ENGINE_CREATION_FORM_TITLE} + + + + + 0 && rawName !== name ? ( + <> + {SANITIZED_NAME_NOTE} {name} + + ) : ( + ALLOWED_CHARS_NOTE + ) + } + fullWidth + > + setRawName(event.currentTarget.value)} + autoComplete="off" + fullWidth + data-test-subj="EngineCreationNameInput" + placeholder={ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER} + autoFocus + /> + + + + + setLanguage(event.currentTarget.value)} + /> + + + + + + {ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL} + + +
+
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts new file mode 100644 index 00000000000000..272e4fb3a25c04 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts @@ -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 { + LogicMounter, + mockHttpValues, + mockKibanaValues, + mockFlashMessageHelpers, +} from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; + +import { EngineCreationLogic } from './engine_creation_logic'; + +describe('EngineCreationLogic', () => { + const { mount } = new LogicMounter(EngineCreationLogic); + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + const { setQueuedSuccessMessage, flashAPIErrors } = mockFlashMessageHelpers; + + const DEFAULT_VALUES = { + name: '', + rawName: '', + language: 'Universal', + }; + + it('has expected default values', () => { + mount(); + expect(EngineCreationLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('setLanguage', () => { + it('sets language to the provided value', () => { + mount(); + EngineCreationLogic.actions.setLanguage('English'); + expect(EngineCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + language: 'English', + }); + }); + }); + + describe('setRawName', () => { + beforeAll(() => { + mount(); + EngineCreationLogic.actions.setRawName('Name__With#$&*%Special--Characters'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should set rawName to provided value', () => { + expect(EngineCreationLogic.values.rawName).toEqual('Name__With#$&*%Special--Characters'); + }); + + it('should set name to a sanitized value', () => { + expect(EngineCreationLogic.values.name).toEqual('name-with-special-characters'); + }); + }); + }); + + describe('listeners', () => { + describe('onEngineCreationSuccess', () => { + beforeAll(() => { + mount({ language: 'English', rawName: 'test' }); + EngineCreationLogic.actions.onEngineCreationSuccess(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should set a success message', () => { + expect(setQueuedSuccessMessage).toHaveBeenCalledWith('Successfully created engine.'); + }); + + it('should navigate the user to the engine page', () => { + expect(navigateToUrl).toHaveBeenCalledWith('/engines/test'); + }); + }); + + describe('submitEngine', () => { + beforeAll(() => { + mount({ language: 'English', rawName: 'test' }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('POSTS to /api/app_search/engines', () => { + const body = JSON.stringify({ + name: EngineCreationLogic.values.name, + language: EngineCreationLogic.values.language, + }); + EngineCreationLogic.actions.submitEngine(); + expect(http.post).toHaveBeenCalledWith('/api/app_search/engines', { body }); + }); + + it('calls onEngineCreationSuccess on valid submission', async () => { + jest.spyOn(EngineCreationLogic.actions, 'onEngineCreationSuccess'); + http.post.mockReturnValueOnce(Promise.resolve({})); + EngineCreationLogic.actions.submitEngine(); + await nextTick(); + expect(EngineCreationLogic.actions.onEngineCreationSuccess).toHaveBeenCalledTimes(1); + }); + + it('calls flashAPIErrors on API Error', async () => { + http.post.mockReturnValueOnce(Promise.reject()); + EngineCreationLogic.actions.submitEngine(); + await nextTick(); + expect(flashAPIErrors).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts new file mode 100644 index 00000000000000..6cea32f826e7a3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts @@ -0,0 +1,81 @@ +/* + * Copyright 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 { generatePath } from 'react-router-dom'; + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors, setQueuedSuccessMessage } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { KibanaLogic } from '../../../shared/kibana'; +import { ENGINE_PATH } from '../../routes'; +import { formatApiName } from '../../utils/format_api_name'; + +import { DEFAULT_LANGUAGE, ENGINE_CREATION_SUCCESS_MESSAGE } from './constants'; + +interface EngineCreationActions { + onEngineCreationSuccess(): void; + setLanguage(language: string): { language: string }; + setRawName(rawName: string): { rawName: string }; + submitEngine(): void; +} + +interface EngineCreationValues { + language: string; + name: string; + rawName: string; +} + +export const EngineCreationLogic = kea>({ + path: ['enterprise_search', 'app_search', 'engine_creation_logic'], + actions: { + onEngineCreationSuccess: true, + setLanguage: (language) => ({ language }), + setRawName: (rawName) => ({ rawName }), + submitEngine: true, + }, + reducers: { + language: [ + DEFAULT_LANGUAGE, + { + setLanguage: (_, { language }) => language, + }, + ], + rawName: [ + '', + { + setRawName: (_, { rawName }) => rawName, + }, + ], + }, + selectors: ({ selectors }) => ({ + name: [() => [selectors.rawName], (rawName) => formatApiName(rawName)], + }), + listeners: ({ values, actions }) => ({ + submitEngine: async () => { + const { http } = HttpLogic.values; + const { name, language } = values; + + const body = JSON.stringify({ name, language }); + + try { + await http.post('/api/app_search/engines', { body }); + actions.onEngineCreationSuccess(); + } catch (e) { + flashAPIErrors(e); + } + }, + onEngineCreationSuccess: () => { + const { name } = values; + const { navigateToUrl } = KibanaLogic.values; + const enginePath = generatePath(ENGINE_PATH, { engineName: name }); + + setQueuedSuccessMessage(ENGINE_CREATION_SUCCESS_MESSAGE); + navigateToUrl(enginePath); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/index.ts new file mode 100644 index 00000000000000..a1770cc50ea93e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/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 { EngineCreation } from './engine_creation'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx index 81bf3716edfb82..f505f08a3531ab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx @@ -16,6 +16,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FlashMessages } from '../../../shared/flash_messages'; import { DOCS_PREFIX } from '../../routes'; import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation'; @@ -41,6 +42,7 @@ export const EmptyEngineOverview: React.FC = () => {
+ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx index 34a154ca837410..c33431354eafc0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx @@ -12,6 +12,8 @@ import { useValues } from 'kea'; import { EuiPageHeader, EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FlashMessages } from '../../../shared/flash_messages'; + import { UnavailablePrompt, TotalStats, TotalCharts, RecentApiLogs } from './components'; import { EngineOverviewLogic } from './'; @@ -30,6 +32,7 @@ export const EngineOverviewMetrics: React.FC = () => { + {apiLogsUnavailable ? ( ) : ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx index ac540eec3ff91c..14772375c9bd49 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx @@ -10,9 +10,9 @@ import { mockTelemetryActions } from '../../../../__mocks__'; import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { EuiEmptyPrompt } from '@elastic/eui'; import { EmptyState } from './'; @@ -23,12 +23,24 @@ describe('EmptyState', () => { expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); }); - it('sends telemetry on create first engine click', () => { - const wrapper = shallow(); - const prompt = wrapper.find(EuiEmptyPrompt).dive(); - const button = prompt.find(EuiButton); - - button.simulate('click'); - expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalled(); + describe('CTA Button', () => { + let wrapper: ShallowWrapper; + let prompt: ShallowWrapper; + let button: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow(); + prompt = wrapper.find(EuiEmptyPrompt).dive(); + button = prompt.find('[data-test-subj="EmptyStateCreateFirstEngineCta"]'); + }); + + it('sends telemetry on create first engine click', () => { + button.simulate('click'); + expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalled(); + }); + + it('sends a user to engine creation', () => { + expect(button.prop('to')).toEqual('/engine_creation'); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx index 5419a175c9eff1..d742d68b0c9d68 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { useActions } from 'kea'; -import { EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { EuiPageContent, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { getAppSearchUrl } from '../../../../shared/enterprise_search_url'; import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; +import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../../shared/telemetry'; -import { CREATE_ENGINES_PATH } from '../../../routes'; +import { ENGINE_CREATION_PATH } from '../../../routes'; import { EnginesOverviewHeader } from './header'; @@ -24,16 +24,6 @@ import './empty_state.scss'; export const EmptyState: React.FC = () => { const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - const buttonProps = { - href: getAppSearchUrl(CREATE_ENGINES_PATH), - target: '_blank', - onClick: () => - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'create_first_engine_button', - }), - }; - return ( <> @@ -60,12 +50,23 @@ export const EmptyState: React.FC = () => {

} actions={ - + + sendAppSearchTelemetry({ + action: 'clicked', + metric: 'create_first_engine_button', + }) + } + > - + } /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts index 8b387668b89f98..401d4ccd6d117b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts @@ -15,3 +15,10 @@ export const META_ENGINES_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.metaEngines.title', { defaultMessage: 'Meta Engines' } ); + +export const CREATE_AN_ENGINE_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engines.createAnEngineButton.ButtonLabel', + { + defaultMessage: 'Create an engine', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx index cdc06dbbe39218..978538d26e5d69 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx @@ -77,6 +77,14 @@ describe('EnginesOverview', () => { expect(actions.loadEngines).toHaveBeenCalled(); }); + it('renders a create engine button which takes users to the create engine page', () => { + const wrapper = shallow(); + + expect( + wrapper.find('[data-test-subj="appSearchEnginesEngineCreationButton"]').prop('to') + ).toEqual('/engine_creation'); + }); + describe('when on a platinum license', () => { it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => { setMockValues({ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index 2835c8b7cb3c4c..1a81c1918ad4d0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -12,6 +12,7 @@ import { useValues, useActions } from 'kea'; import { EuiPageContent, EuiPageContentHeader, + EuiPageContentHeaderSection, EuiPageContentBody, EuiTitle, EuiSpacer, @@ -20,12 +21,14 @@ import { import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { LicensingLogic } from '../../../shared/licensing'; +import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { ENGINE_CREATION_PATH } from '../../routes'; import { EngineIcon } from './assets/engine_icon'; import { MetaEngineIcon } from './assets/meta_engine_icon'; import { EnginesOverviewHeader, LoadingState, EmptyState } from './components'; -import { ENGINES_TITLE, META_ENGINES_TITLE } from './constants'; +import { CREATE_AN_ENGINE_BUTTON_LABEL, ENGINES_TITLE, META_ENGINES_TITLE } from './constants'; import { EnginesLogic } from './engines_logic'; import { EnginesTable } from './engines_table'; @@ -65,12 +68,24 @@ export const EnginesOverview: React.FC = () => { - - -

- {ENGINES_TITLE} -

-
+ + + +

+ {ENGINES_TITLE} +

+
+
+ + + {CREATE_AN_ENGINE_BUTTON_LABEL} + +
{ }); describe('ability checks', () => { - // TODO: Use this section for routes wrapped in canViewX conditionals - // e.g., it('renders settings if a user can view settings') + describe('canManageEngines', () => { + it('renders EngineCreation when user canManageEngines is true', () => { + setMockValues({ myRole: { canManageEngines: true } }); + const wrapper = shallow(); + + expect(wrapper.find(EngineCreation)).toHaveLength(1); + }); + + it('does not render EngineCreation when user canManageEngines is false', () => { + setMockValues({ myRole: { canManageEngines: false } }); + const wrapper = shallow(); + + expect(wrapper.find(EngineCreation)).toHaveLength(0); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 36ac3fb4dbc5b8..40dfc1426e4029 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -21,6 +21,7 @@ import { NotFound } from '../shared/not_found'; import { AppLogic } from './app_logic'; import { Credentials, CREDENTIALS_TITLE } from './components/credentials'; import { EngineNav, EngineRouter } from './components/engine'; +import { EngineCreation } from './components/engine_creation'; import { EnginesOverview, ENGINES_TITLE } from './components/engines'; import { ErrorConnecting } from './components/error_connecting'; import { Library } from './components/library'; @@ -28,6 +29,7 @@ import { ROLE_MAPPINGS_TITLE } from './components/role_mappings'; import { Settings, SETTINGS_TITLE } from './components/settings'; import { SetupGuide } from './components/setup_guide'; import { + ENGINE_CREATION_PATH, ROOT_PATH, SETUP_GUIDE_PATH, SETTINGS_PATH, @@ -56,7 +58,10 @@ export const AppSearchUnconfigured: React.FC = () => ( export const AppSearchConfigured: React.FC = (props) => { const { initializeAppData } = useActions(AppLogic); - const { hasInitialized } = useValues(AppLogic); + const { + hasInitialized, + myRole: { canManageEngines }, + } = useValues(AppLogic); const { errorConnecting, readOnlyMode } = useValues(HttpLogic); useEffect(() => { @@ -96,6 +101,11 @@ export const AppSearchConfigured: React.FC = (props) => { + {canManageEngines && ( + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index 962efbb7ece3ab..dee8858fada8b4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -17,7 +17,7 @@ export const CREDENTIALS_PATH = '/credentials'; export const ROLE_MAPPINGS_PATH = '#/role-mappings'; // This page seems to 404 if the # isn't included export const ENGINES_PATH = '/engines'; -export const CREATE_ENGINES_PATH = `${ENGINES_PATH}/new`; +export const ENGINE_CREATION_PATH = '/engine_creation'; export const ENGINE_PATH = `${ENGINES_PATH}/:engineName`; export const SAMPLE_ENGINE_PATH = `${ENGINES_PATH}/national-parks-demo`; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index abd26e18c7b9d8..6fbc9f5bd2fc49 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -110,6 +110,47 @@ describe('engine routes', () => { }); }); + describe('POST /api/app_search/engines', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines', + }); + + registerEnginesRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({ body: { name: 'some-engine', language: 'en' } }); + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/collection', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { body: { name: 'some-engine', language: 'en' } }; + mockRouter.shouldValidate(request); + }); + + it('missing name', () => { + const request = { body: { language: 'en' } }; + mockRouter.shouldThrow(request); + }); + + it('optional language', () => { + const request = { body: { name: 'some-engine' } }; + mockRouter.shouldValidate(request); + }); + }); + }); + describe('GET /api/app_search/engines/{name}', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index 0070680985a348..7d537e5dc0df36 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -45,6 +45,21 @@ export function registerEnginesRoutes({ } ); + router.post( + { + path: '/api/app_search/engines', + validate: { + body: schema.object({ + name: schema.string(), + language: schema.maybe(schema.string()), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/collection', + }) + ); + // Single engine endpoints router.get( { From e3f672926efa2129d12295581d11956af9f337e1 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 11 Feb 2021 15:52:45 +0200 Subject: [PATCH 21/24] [XY Plugin] Add unit tests (#89582) * [XY Plugin] Add unit tests * More unit tests * Address PR comments * Update license * Resolve PR comments * A nice improvement * Apply new type everywhere * Cleaning up Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/detailed_tooltip.mock.ts | 189 ++++ .../components/detailed_tooltip.test.tsx | 62 ++ .../public/components/detailed_tooltip.tsx | 2 +- .../common/truncate_labels.test.tsx | 51 ++ .../components/common/truncate_labels.tsx | 3 +- .../point_series/point_series.mocks.ts | 867 ++++++++++++++++++ .../point_series/point_series.test.tsx | 165 ++++ .../options/point_series/threshold_panel.tsx | 1 + .../public/utils/get_all_series.test.ts | 2 +- .../public/utils/get_series_name_fn.test.ts | 145 +++ 10 files changed, 1484 insertions(+), 3 deletions(-) create mode 100644 src/plugins/vis_type_xy/public/components/detailed_tooltip.mock.ts create mode 100644 src/plugins/vis_type_xy/public/components/detailed_tooltip.test.tsx create mode 100644 src/plugins/vis_type_xy/public/editor/components/common/truncate_labels.test.tsx create mode 100644 src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts create mode 100644 src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx create mode 100644 src/plugins/vis_type_xy/public/utils/get_series_name_fn.test.ts diff --git a/src/plugins/vis_type_xy/public/components/detailed_tooltip.mock.ts b/src/plugins/vis_type_xy/public/components/detailed_tooltip.mock.ts new file mode 100644 index 00000000000000..25310ea1ee7ff6 --- /dev/null +++ b/src/plugins/vis_type_xy/public/components/detailed_tooltip.mock.ts @@ -0,0 +1,189 @@ +/* + * Copyright 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 const aspects = { + x: { + accessor: 'col-0-3', + column: 0, + title: 'timestamp per 3 hours', + format: { + id: 'date', + params: { + pattern: 'YYYY-MM-DD HH:mm', + }, + }, + aggType: 'date_histogram', + aggId: '3', + params: { + date: true, + intervalESUnit: 'h', + intervalESValue: 3, + interval: 10800000, + format: 'YYYY-MM-DD HH:mm', + }, + }, + y: [ + { + accessor: 'col-1-1', + column: 1, + title: 'Count', + format: { + id: 'number', + }, + aggType: 'count', + aggId: '1', + params: {}, + }, + ], +}; + +export const aspectsWithSplitColumn = { + x: { + accessor: 'col-0-3', + column: 0, + title: 'timestamp per 3 hours', + format: { + id: 'date', + params: { + pattern: 'YYYY-MM-DD HH:mm', + }, + }, + aggType: 'date_histogram', + aggId: '3', + params: { + date: true, + intervalESUnit: 'h', + intervalESValue: 3, + interval: 10800000, + format: 'YYYY-MM-DD HH:mm', + }, + }, + y: [ + { + accessor: 'col-2-1', + column: 2, + title: 'Count', + format: { + id: 'number', + }, + aggType: 'count', + aggId: '1', + params: {}, + }, + ], + splitColumn: { + accessor: 'col-1-4', + column: 1, + title: 'Cancelled: Descending', + format: { + id: 'terms', + params: { + id: 'boolean', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + aggType: 'terms', + aggId: '4', + params: {}, + }, +}; + +export const aspectsWithSplitRow = { + x: { + accessor: 'col-0-3', + column: 0, + title: 'timestamp per 3 hours', + format: { + id: 'date', + params: { + pattern: 'YYYY-MM-DD HH:mm', + }, + }, + aggType: 'date_histogram', + aggId: '3', + params: { + date: true, + intervalESUnit: 'h', + intervalESValue: 3, + interval: 10800000, + format: 'YYYY-MM-DD HH:mm', + }, + }, + y: [ + { + accessor: 'col-3-1', + column: 2, + title: 'Count', + format: { + id: 'number', + }, + aggType: 'count', + aggId: '1', + params: {}, + }, + ], + splitRow: { + accessor: 'col-1-5', + column: 1, + title: 'Carrier: Descending', + format: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + aggType: 'terms', + aggId: '4', + params: {}, + }, +}; + +export const header = { + seriesIdentifier: { + key: + 'groupId{__pseudo_stacked_group-ValueAxis-1__}spec{area-col-1-1}yAccessor{col-1-1}splitAccessors{}smV{__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__}smH{__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__}', + specId: 'area-col-1-1', + yAccessor: 'col-1-1', + splitAccessors: {}, + seriesKeys: ['col-1-1'], + smVerticalAccessorValue: '__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__', + smHorizontalAccessorValue: '__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__', + }, + valueAccessor: 'y1', + label: 'Count', + value: 1611817200000, + formattedValue: '1611817200000', + markValue: null, + color: '#54b399', + isHighlighted: false, + isVisible: true, +}; + +export const value = { + seriesIdentifier: { + key: + 'groupId{__pseudo_stacked_group-ValueAxis-1__}spec{area-col-1-1}yAccessor{col-1-1}splitAccessors{}smV{__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__}smH{__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__}', + specId: 'area-col-1-1', + yAccessor: 'col-1-1', + splitAccessors: [], + seriesKeys: ['col-1-1'], + smVerticalAccessorValue: 'kibana', + smHorizontalAccessorValue: 'false', + }, + valueAccessor: 'y1', + label: 'Count', + value: 52, + formattedValue: '52', + markValue: null, + color: '#54b399', + isHighlighted: true, + isVisible: true, +}; diff --git a/src/plugins/vis_type_xy/public/components/detailed_tooltip.test.tsx b/src/plugins/vis_type_xy/public/components/detailed_tooltip.test.tsx new file mode 100644 index 00000000000000..aa76b680f6cc05 --- /dev/null +++ b/src/plugins/vis_type_xy/public/components/detailed_tooltip.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getTooltipData } from './detailed_tooltip'; +import { + aspects, + aspectsWithSplitColumn, + aspectsWithSplitRow, + header, + value, +} from './detailed_tooltip.mock'; + +describe('getTooltipData', () => { + it('returns an array with the header and data information', () => { + const tooltipData = getTooltipData(aspects, header, value); + expect(tooltipData).toStrictEqual([ + { + label: 'timestamp per 3 hours', + value: '1611817200000', + }, + { + label: 'Count', + value: '52', + }, + ]); + }); + + it('returns an array with the data information if the header is not applied', () => { + const tooltipData = getTooltipData(aspects, null, value); + expect(tooltipData).toStrictEqual([ + { + label: 'Count', + value: '52', + }, + ]); + }); + + it('returns an array with the split column information if it is provided', () => { + const tooltipData = getTooltipData(aspectsWithSplitColumn, null, value); + expect(tooltipData).toStrictEqual([ + { + label: 'Cancelled: Descending', + value: 'false', + }, + ]); + }); + + it('returns an array with the split row information if it is provided', () => { + const tooltipData = getTooltipData(aspectsWithSplitRow, null, value); + expect(tooltipData).toStrictEqual([ + { + label: 'Carrier: Descending', + value: 'kibana', + }, + ]); + }); +}); diff --git a/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx b/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx index 75b5041dae49fa..0c1ab262755a73 100644 --- a/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx +++ b/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx @@ -30,7 +30,7 @@ interface TooltipData { // TODO: replace when exported from elastic/charts const DEFAULT_SINGLE_PANEL_SM_VALUE = '__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__'; -const getTooltipData = ( +export const getTooltipData = ( aspects: Aspects, header: TooltipValue | null, value: TooltipValue diff --git a/src/plugins/vis_type_xy/public/editor/components/common/truncate_labels.test.tsx b/src/plugins/vis_type_xy/public/editor/components/common/truncate_labels.test.tsx new file mode 100644 index 00000000000000..902167cb246429 --- /dev/null +++ b/src/plugins/vis_type_xy/public/editor/components/common/truncate_labels.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 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 { mountWithIntl } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import { TruncateLabelsOption, TruncateLabelsOptionProps } from './truncate_labels'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('TruncateLabelsOption', function () { + let props: TruncateLabelsOptionProps; + let component: ReactWrapper; + + beforeAll(() => { + props = { + disabled: false, + value: 20, + setValue: jest.fn(), + }; + }); + + it('renders an input type number', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'xyLabelTruncateInput').length).toBe(1); + }); + + it('renders the value on the input number', () => { + component = mountWithIntl(); + const input = findTestSubject(component, 'xyLabelTruncateInput'); + expect(input.props().value).toBe(20); + }); + + it('disables the input if disabled prop is given', () => { + const newProps = { ...props, disabled: true }; + component = mountWithIntl(); + const input = findTestSubject(component, 'xyLabelTruncateInput'); + expect(input.props().disabled).toBeTruthy(); + }); + + it('should set the new value', () => { + component = mountWithIntl(); + const input = findTestSubject(component, 'xyLabelTruncateInput'); + input.simulate('change', { target: { value: 100 } }); + expect(props.setValue).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_xy/public/editor/components/common/truncate_labels.tsx b/src/plugins/vis_type_xy/public/editor/components/common/truncate_labels.tsx index ee192257fa5454..4ce95b4c617bea 100644 --- a/src/plugins/vis_type_xy/public/editor/components/common/truncate_labels.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/common/truncate_labels.tsx @@ -10,7 +10,7 @@ import React, { ChangeEvent } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiFieldNumber } from '@elastic/eui'; -interface TruncateLabelsOptionProps { +export interface TruncateLabelsOptionProps { disabled?: boolean; value?: number | null; setValue: (paramName: 'truncate', value: null | number) => void; @@ -29,6 +29,7 @@ function TruncateLabelsOption({ disabled, value = null, setValue }: TruncateLabe display="rowCompressed" > { + return { + indexPattern: { + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + title: 'kibana_sample_data_flights', + fieldFormatMap: { + hour_of_day: { + id: 'number', + params: { + pattern: '00', + }, + }, + AvgTicketPrice: { + id: 'number', + params: { + pattern: '$0,0.[00]', + }, + }, + }, + fields: [ + { + count: 4, + name: 'AvgTicketPrice', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Cancelled', + type: 'boolean', + esTypes: ['boolean'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 3, + name: 'Carrier', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 2, + name: 'timestamp', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + ], + timeFieldName: 'timestamp', + metaFields: ['_source', '_id', '_type', '_index', '_score'], + version: 'WzQ3NjYsMl0=', + originalSavedObjectBody: { + fieldAttrs: + '{"AvgTicketPrice":{"count":4},"Carrier":{"count":3},"DestAirportID":{"count":1},"DestCityName":{"count":3},"DestCountry":{"count":3},"DestLocation":{"count":1},"_score":{"count":1},"dayOfWeek":{"count":4},"timestamp":{"count":2}}', + title: 'kibana_sample_data_flights', + timeFieldName: 'timestamp', + fields: + '[{"count":0,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', + fieldFormatMap: + '{"hour_of_day":{"id":"number","params":{"pattern":"00"}},"AvgTicketPrice":{"id":"number","params":{"pattern":"$0,0.[00]"}}}', + runtimeFieldMap: '{}', + }, + shortDotsEnable: false, + fieldFormats: { + fieldFormats: {}, + defaultMap: { + ip: { + id: 'ip', + params: {}, + }, + date: { + id: 'date', + params: {}, + }, + date_nanos: { + id: 'date_nanos', + params: {}, + es: true, + }, + number: { + id: 'number', + params: {}, + }, + boolean: { + id: 'boolean', + params: {}, + }, + _source: { + id: '_source', + params: {}, + }, + _default_: { + id: 'string', + params: {}, + }, + }, + metaParamsOptions: { + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/thz/app/visualize', + basePath: '/thz', + }, + }, + }, + fieldAttrs: { + AvgTicketPrice: { + count: 4, + }, + Carrier: { + count: 3, + }, + timestamp: { + count: 2, + }, + }, + runtimeFieldMap: {}, + allowNoIndex: false, + }, + typesRegistry: {}, + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'date_histogram', + params: { + field: 'timestamp', + timeRange: { + from: 'now-15m', + to: 'now', + }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: 'auto', + drop_partials: false, + min_doc_count: 1, + extended_bounds: {}, + }, + schema: 'segment', + }, + ], + }; +}; + +export const getVis = (bucketType: string) => { + return { + data: { + aggs: { + indexPattern: { + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + title: 'kibana_sample_data_flights', + fieldFormatMap: { + hour_of_day: { + id: 'number', + params: { + pattern: '00', + }, + }, + AvgTicketPrice: { + id: 'number', + params: { + pattern: '$0,0.[00]', + }, + }, + }, + fields: [ + { + count: 4, + name: 'AvgTicketPrice', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Cancelled', + type: 'boolean', + esTypes: ['boolean'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 3, + name: 'Carrier', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 2, + name: 'timestamp', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + ], + timeFieldName: 'timestamp', + metaFields: ['_source', '_id', '_type', '_index', '_score'], + version: 'WzQ3NjYsMl0=', + originalSavedObjectBody: { + fieldAttrs: + '{"AvgTicketPrice":{"count":4},"Carrier":{"count":3},"DestAirportID":{"count":1},"DestCityName":{"count":3},"DestCountry":{"count":3},"DestLocation":{"count":1},"_score":{"count":1},"dayOfWeek":{"count":4},"timestamp":{"count":2}}', + title: 'kibana_sample_data_flights', + timeFieldName: 'timestamp', + fields: + '[{"count":0,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', + fieldFormatMap: + '{"hour_of_day":{"id":"number","params":{"pattern":"00"}},"AvgTicketPrice":{"id":"number","params":{"pattern":"$0,0.[00]"}}}', + runtimeFieldMap: '{}', + }, + shortDotsEnable: false, + fieldFormats: { + fieldFormats: {}, + defaultMap: { + ip: { + id: 'ip', + params: {}, + }, + date: { + id: 'date', + params: {}, + }, + date_nanos: { + id: 'date_nanos', + params: {}, + es: true, + }, + number: { + id: 'number', + params: {}, + }, + boolean: { + id: 'boolean', + params: {}, + }, + _source: { + id: '_source', + params: {}, + }, + _default_: { + id: 'string', + params: {}, + }, + }, + metaParamsOptions: { + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/thz/app/visualize', + basePath: '/thz', + }, + }, + }, + fieldAttrs: { + AvgTicketPrice: { + count: 4, + }, + Carrier: { + count: 3, + }, + timestamp: { + count: 2, + }, + }, + runtimeFieldMap: {}, + allowNoIndex: false, + }, + typesRegistry: {}, + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: { + name: bucketType, + }, + params: { + field: 'timestamp', + timeRange: { + from: 'now-15m', + to: 'now', + }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: 'auto', + drop_partials: false, + min_doc_count: 1, + extended_bounds: {}, + }, + schema: 'segment', + }, + ], + }, + }, + type: { + name: 'area', + title: 'Area', + description: 'Emphasize the data between an axis and a line.', + note: '', + icon: 'visArea', + stage: 'production', + group: 'aggbased', + titleInWizard: '', + options: { + showTimePicker: true, + showQueryBar: true, + showFilterBar: true, + showIndexSelection: true, + hierarchicalData: false, + }, + visConfig: { + defaults: { + type: 'area', + grid: { + categoryLines: false, + }, + categoryAxes: [ + { + id: 'CategoryAxis-1', + type: 'category', + position: 'bottom', + show: true, + scale: { + type: 'linear', + }, + labels: { + show: true, + filter: true, + truncate: 100, + }, + title: {}, + style: {}, + }, + ], + valueAxes: [ + { + id: 'ValueAxis-1', + name: 'LeftAxis-1', + type: 'value', + position: 'left', + show: true, + scale: { + type: 'linear', + mode: 'normal', + }, + labels: { + show: true, + rotate: 0, + filter: false, + truncate: 100, + }, + title: { + text: 'Count', + }, + style: {}, + }, + ], + seriesParams: [ + { + show: true, + type: 'area', + mode: 'stacked', + data: { + label: 'Count', + id: '1', + }, + drawLinesBetweenPoints: true, + lineWidth: 2, + showCircles: true, + interpolate: 'linear', + valueAxis: 'ValueAxis-1', + }, + ], + addTooltip: true, + detailedTooltip: true, + palette: { + type: 'palette', + name: 'default', + }, + addLegend: true, + legendPosition: 'right', + fittingFunction: 'linear', + times: [], + addTimeMarker: false, + radiusRatio: 9, + thresholdLine: { + show: false, + value: 10, + width: 1, + style: 'full', + color: '#E7664C', + }, + labels: {}, + }, + }, + editorConfig: { + collections: { + legendPositions: [ + { + text: 'Top', + value: 'top', + }, + { + text: 'Left', + value: 'left', + }, + { + text: 'Right', + value: 'right', + }, + { + text: 'Bottom', + value: 'bottom', + }, + ], + positions: [ + { + text: 'Top', + value: 'top', + }, + { + text: 'Left', + value: 'left', + }, + { + text: 'Right', + value: 'right', + }, + { + text: 'Bottom', + value: 'bottom', + }, + ], + chartTypes: [ + { + text: 'Line', + value: 'line', + }, + { + text: 'Area', + value: 'area', + }, + { + text: 'Bar', + value: 'histogram', + }, + ], + axisModes: [ + { + text: 'Normal', + value: 'normal', + }, + { + text: 'Percentage', + value: 'percentage', + }, + { + text: 'Wiggle', + value: 'wiggle', + }, + { + text: 'Silhouette', + value: 'silhouette', + }, + ], + scaleTypes: [ + { + text: 'Linear', + value: 'linear', + }, + { + text: 'Log', + value: 'log', + }, + { + text: 'Square root', + value: 'square root', + }, + ], + chartModes: [ + { + text: 'Normal', + value: 'normal', + }, + { + text: 'Stacked', + value: 'stacked', + }, + ], + interpolationModes: [ + { + text: 'Straight', + value: 'linear', + }, + { + text: 'Smoothed', + value: 'cardinal', + }, + { + text: 'Stepped', + value: 'step-after', + }, + ], + thresholdLineStyles: [ + { + value: 'full', + text: 'Full', + }, + { + value: 'dashed', + text: 'Dashed', + }, + { + value: 'dot-dashed', + text: 'Dot-dashed', + }, + ], + fittingFunctions: [ + { + value: 'none', + text: 'Hide (Do not fill gaps)', + }, + { + value: 'zero', + text: 'Zero (Fill gaps with zeros)', + }, + { + value: 'linear', + text: 'Linear (Fill gaps with a line)', + }, + { + value: 'carry', + text: 'Last (Fill gaps with the last value)', + }, + { + value: 'lookahead', + text: 'Next (Fill gaps with the next value)', + }, + ], + }, + optionTabs: [ + { + name: 'advanced', + title: 'Metrics & axes', + }, + { + name: 'options', + title: 'Panel settings', + }, + ], + schemas: [ + { + group: 'metrics', + name: 'metric', + title: 'Y-axis', + aggFilter: ['!geo_centroid', '!geo_bounds'], + min: 1, + defaults: [ + { + schema: 'metric', + type: 'count', + }, + ], + max: null, + params: [], + }, + { + group: 'metrics', + name: 'radius', + title: 'Dot size', + min: 0, + max: 1, + aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality'], + params: [], + }, + { + group: 'buckets', + name: 'segment', + title: 'X-axis', + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [], + }, + { + group: 'buckets', + name: 'group', + title: 'Split series', + min: 0, + max: 3, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [], + }, + { + group: 'buckets', + name: 'split', + title: 'Split chart', + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [ + { + name: 'row', + default: true, + }, + ], + }, + ], + }, + hidden: false, + requiresSearch: true, + hierarchicalData: false, + schemas: { + all: [ + { + group: 'metrics', + name: 'metric', + title: 'Y-axis', + aggFilter: ['!geo_centroid', '!geo_bounds'], + min: 1, + defaults: [ + { + schema: 'metric', + type: 'count', + }, + ], + max: null, + params: [], + }, + { + group: 'metrics', + name: 'radius', + title: 'Dot size', + min: 0, + max: 1, + aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality'], + params: [], + }, + { + group: 'buckets', + name: 'segment', + title: 'X-axis', + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [], + }, + { + group: 'buckets', + name: 'group', + title: 'Split series', + min: 0, + max: 3, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [], + }, + { + group: 'buckets', + name: 'split', + title: 'Split chart', + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [ + { + name: 'row', + default: true, + }, + ], + }, + ], + buckets: [ + { + group: 'buckets', + name: 'segment', + title: 'X-axis', + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [], + }, + { + group: 'buckets', + name: 'group', + title: 'Split series', + min: 0, + max: 3, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [], + }, + { + group: 'buckets', + name: 'split', + title: 'Split chart', + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [ + { + name: 'row', + default: true, + }, + ], + }, + ], + metrics: [ + { + group: 'metrics', + name: 'metric', + title: 'Y-axis', + aggFilter: ['!geo_centroid', '!geo_bounds'], + min: 1, + defaults: [ + { + schema: 'metric', + type: 'count', + }, + ], + max: null, + params: [], + }, + { + group: 'metrics', + name: 'radius', + title: 'Dot size', + min: 0, + max: 1, + aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality'], + params: [], + }, + ], + }, + }, + }; +}; + +export const getStateParams = (type: string, thresholdPanelOn: boolean) => { + return { + type: 'area', + 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, + filter: true, + }, + 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, + mode: 'stacked', + data: { + label: 'Count', + id: '1', + }, + drawLinesBetweenPoints: true, + showCircles: true, + interpolate: 'cardinal', + valueAxis: 'ValueAxis-1', + }, + ], + addTooltip: true, + addLegend: true, + legendPosition: 'right', + times: [], + addTimeMarker: false, + detailedTooltip: true, + palette: { + type: 'palette', + name: 'kibana_palette', + }, + isVislibVis: true, + fittingFunction: 'zero', + radiusRatio: 9, + thresholdLine: { + show: thresholdPanelOn, + value: 10, + width: 1, + style: 'full', + color: '#E7664C', + }, + labels: {}, + }; +}; diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx new file mode 100644 index 00000000000000..59c03e02ac9f45 --- /dev/null +++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { mountWithIntl } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import { PointSeriesOptions } from './point_series'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from 'react-dom/test-utils'; +import { ChartType } from '../../../../../common'; +import { getAggs, getVis, getStateParams } from './point_series.mocks'; + +jest.mock('../../../../services', () => ({ + getTrackUiMetric: jest.fn(() => null), + getPalettesService: jest.fn(() => { + return { + getPalettes: jest.fn(), + }; + }), +})); + +type PointSeriesOptionsProps = Parameters[0]; + +describe('PointSeries Editor', function () { + let props: PointSeriesOptionsProps; + let component: ReactWrapper; + + beforeEach(() => { + props = ({ + aggs: getAggs(), + hasHistogramAgg: false, + extraProps: { + showElasticChartsOptions: false, + }, + isTabSelected: false, + setMultipleValidity: jest.fn(), + setTouched: jest.fn(), + setValue: jest.fn(), + setValidity: jest.fn(), + stateParams: getStateParams(ChartType.Histogram, false), + vis: getVis('date_histogram'), + } as unknown) as PointSeriesOptionsProps; + }); + + it('renders the showValuesOnChart switch for a bar chart', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'showValuesOnChart')).toHaveLength(1); + }); + }); + + it('not renders the showValuesOnChart switch for an area chart', async () => { + const areaVisProps = ({ + ...props, + stateParams: getStateParams(ChartType.Area, false), + } as unknown) as PointSeriesOptionsProps; + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'showValuesOnChart').length).toBe(0); + }); + }); + + it('renders the addTimeMarker switch for a date histogram bucket', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'addTimeMarker').length).toBe(1); + expect(findTestSubject(component, 'orderBucketsBySum').length).toBe(0); + }); + }); + + it('renders the orderBucketsBySum switch for a non date histogram bucket', async () => { + const newVisProps = ({ + ...props, + vis: getVis('terms'), + } as unknown) as PointSeriesOptionsProps; + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'addTimeMarker').length).toBe(0); + expect(findTestSubject(component, 'orderBucketsBySum').length).toBe(1); + }); + }); + + it('not renders the editor options that are specific for the es charts implementation if showElasticChartsOptions is false', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'detailedTooltip').length).toBe(0); + }); + }); + + it('renders the editor options that are specific for the es charts implementation if showElasticChartsOptions is true', async () => { + const newVisProps = ({ + ...props, + extraProps: { + showElasticChartsOptions: true, + }, + } as unknown) as PointSeriesOptionsProps; + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'detailedTooltip').length).toBe(1); + }); + }); + + it('not renders the fitting function for a bar chart', async () => { + const newVisProps = ({ + ...props, + extraProps: { + showElasticChartsOptions: true, + }, + } as unknown) as PointSeriesOptionsProps; + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'fittingFunction').length).toBe(0); + }); + }); + + it('renders the fitting function for a line chart', async () => { + const newVisProps = ({ + ...props, + stateParams: getStateParams(ChartType.Line, false), + extraProps: { + showElasticChartsOptions: true, + }, + } as unknown) as PointSeriesOptionsProps; + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'fittingFunction').length).toBe(1); + }); + }); + + it('renders the showCategoryLines switch', async () => { + const newVisProps = ({ + ...props, + extraProps: { + showElasticChartsOptions: true, + }, + } as unknown) as PointSeriesOptionsProps; + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'showValuesOnChart').length).toBe(1); + }); + }); + + it('not renders the threshold panel if the Show threshold line switch is off', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'thresholdValueInputOption').length).toBe(0); + }); + }); + + it('renders the threshold panel if the Show threshold line switch is on', async () => { + const newVisProps = ({ + ...props, + stateParams: getStateParams(ChartType.Histogram, true), + } as unknown) as PointSeriesOptionsProps; + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'thresholdValueInputOption').length).toBe(1); + }); + }); +}); diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/threshold_panel.tsx b/src/plugins/vis_type_xy/public/editor/components/options/point_series/threshold_panel.tsx index dadbe4dd1fc768..00429c6702eeb2 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/threshold_panel.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/threshold_panel.tsx @@ -78,6 +78,7 @@ function ThresholdPanel({ value={stateParams.thresholdLine.value} setValue={setThresholdLine} setValidity={setThresholdLineValidity} + data-test-subj="thresholdValueInputOption" /> { +describe('getAllSeries', () => { it('returns empty array if splitAccessors is undefined', () => { const splitAccessors = undefined; const series = getAllSeries(rowsNoSplitSeries, splitAccessors, yAspects); diff --git a/src/plugins/vis_type_xy/public/utils/get_series_name_fn.test.ts b/src/plugins/vis_type_xy/public/utils/get_series_name_fn.test.ts new file mode 100644 index 00000000000000..8853e6075e2699 --- /dev/null +++ b/src/plugins/vis_type_xy/public/utils/get_series_name_fn.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright 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 { XYChartSeriesIdentifier } from '@elastic/charts'; +import { getSeriesNameFn } from './get_series_name_fn'; + +const aspects = { + series: [ + { + accessor: 'col-1-3', + column: 1, + title: 'FlightDelayType: Descending', + format: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + aggType: 'terms', + aggId: '3', + params: {}, + }, + ], + x: { + accessor: 'col-0-2', + column: 0, + title: 'timestamp per day', + format: { + id: 'date', + params: { + pattern: 'YYYY-MM-DD', + }, + }, + aggType: 'date_histogram', + aggId: '2', + params: { + date: true, + intervalESUnit: 'd', + intervalESValue: 1, + interval: 86400000, + format: 'YYYY-MM-DD', + }, + }, + y: [ + { + accessor: 'col-1-1', + column: 1, + title: 'Count', + format: { + id: 'number', + }, + aggType: 'count', + aggId: '1', + params: {}, + }, + ], +}; + +const series = ({ + specId: 'histogram-col-1-1', + seriesKeys: ['col-1-1'], + yAccessor: 'col-1-1', + splitAccessors: [], + smVerticalAccessorValue: '__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__', + smHorizontalAccessorValue: '__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__', + groupId: '__pseudo_stacked_group-ValueAxis-1__', + seriesType: 'bar', + isStacked: true, +} as unknown) as XYChartSeriesIdentifier; + +const splitAccessors = new Map(); +splitAccessors.set('col-1-3', 'Weather Delay'); + +const seriesSplitAccessors = ({ + specId: 'histogram-col-2-1', + seriesKeys: ['Weather Delay', 'col-2-1'], + yAccessor: 'col-2-1', + splitAccessors, + smVerticalAccessorValue: '__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__', + smHorizontalAccessorValue: '__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__', + groupId: '__pseudo_stacked_group-ValueAxis-1__', + seriesType: 'bar', + isStacked: true, +} as unknown) as XYChartSeriesIdentifier; + +describe('getSeriesNameFn', () => { + it('returns the y aspects title if splitAccessors are empty array', () => { + const getSeriesName = getSeriesNameFn(aspects, false); + expect(getSeriesName(series)).toStrictEqual('Count'); + }); + + it('returns the y aspects title if splitAccessors are empty array but mupliple flag is set to true', () => { + const getSeriesName = getSeriesNameFn(aspects, true); + expect(getSeriesName(series)).toStrictEqual('Count'); + }); + + it('returns the correct string for multiple set to false and given split accessors', () => { + const aspectsSplitSeries = { + ...aspects, + y: [ + { + accessor: 'col-2-1', + column: 2, + title: 'Count', + format: { + id: 'number', + }, + aggType: 'count', + aggId: '1', + params: {}, + }, + ], + }; + const getSeriesName = getSeriesNameFn(aspectsSplitSeries, false); + expect(getSeriesName(seriesSplitAccessors)).toStrictEqual('Weather Delay'); + }); + + it('returns the correct string for multiple set to true and given split accessors', () => { + const aspectsSplitSeries = { + ...aspects, + y: [ + { + accessor: 'col-2-1', + column: 2, + title: 'Count', + format: { + id: 'number', + }, + aggType: 'count', + aggId: '1', + params: {}, + }, + ], + }; + const getSeriesName = getSeriesNameFn(aspectsSplitSeries, true); + expect(getSeriesName(seriesSplitAccessors)).toStrictEqual('Weather Delay: Count'); + }); +}); From 847d57b3e1057fb82578fde3785117c1847726f9 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 11 Feb 2021 16:12:01 +0200 Subject: [PATCH 22/24] [XY axis] Fixes bug on bar charts preventing unstacked mode (#90602) --- src/plugins/vis_type_xy/public/config/get_config.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/plugins/vis_type_xy/public/config/get_config.ts b/src/plugins/vis_type_xy/public/config/get_config.ts index b19366fc22dbb9..8ebac1b71940a1 100644 --- a/src/plugins/vis_type_xy/public/config/get_config.ts +++ b/src/plugins/vis_type_xy/public/config/get_config.ts @@ -98,10 +98,6 @@ const shouldEnableHistogramMode = ( ); }); - if (bars.length === 1) { - return true; - } - const groupIds = [ ...bars.reduce>((acc, { valueAxis: groupId, mode }) => { acc.add(groupId); @@ -113,11 +109,9 @@ const shouldEnableHistogramMode = ( return false; } - const test = bars.every(({ valueAxis: groupId, mode }) => { + return bars.every(({ valueAxis: groupId, mode }) => { const yAxisScale = yAxes.find(({ groupId: axisGroupId }) => axisGroupId === groupId)?.scale; return mode === 'stacked' || yAxisScale?.mode === 'percentage'; }); - - return test; }; From 6bd0a7fcc5f477b75ca173ee0de11ebcd2898f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Thu, 11 Feb 2021 14:36:17 +0000 Subject: [PATCH 23/24] [Plugins Discovery] Enforce camelCase plugin IDs (#90752) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../discovery/plugin_manifest_parser.test.ts | 77 ++++++++----------- .../discovery/plugin_manifest_parser.ts | 11 +-- .../plugins/discovery/plugins_discovery.ts | 2 +- .../fixtures/plugins/newsfeed/kibana.json | 2 +- .../plugins/kbn_tp_run_pipeline/kibana.json | 2 +- .../plugins/app_link_test/kibana.json | 2 +- .../plugins/core_app_status/kibana.json | 2 +- .../plugins/core_plugin_a/kibana.json | 2 +- .../plugins/core_plugin_appleave/kibana.json | 2 +- .../plugins/core_plugin_b/kibana.json | 6 +- .../plugins/core_plugin_b/public/plugin.tsx | 4 +- .../core_plugin_chromeless/kibana.json | 2 +- .../plugins/core_plugin_helpmenu/kibana.json | 2 +- .../core_plugin_route_timeouts/kibana.json | 2 +- .../plugins/core_provider_plugin/kibana.json | 4 +- .../plugins/data_search/kibana.json | 2 +- .../elasticsearch_client_plugin/kibana.json | 2 +- .../plugins/index_patterns/kibana.json | 2 +- .../kbn_sample_panel_action/kibana.json | 2 +- .../plugins/kbn_top_nav/kibana.json | 4 +- .../kbn_tp_custom_visualizations/kibana.json | 2 +- .../management_test_plugin/kibana.json | 2 +- .../plugins/rendering_plugin/kibana.json | 2 +- .../plugins/session_notifications/kibana.json | 4 +- .../plugins/ui_settings_plugin/kibana.json | 2 +- .../test_suites/core_plugins/ui_plugins.ts | 6 +- .../common/fixtures/plugins/aad/kibana.json | 2 +- .../plugins/actions_simulators/kibana.json | 2 +- .../plugins/task_manager_fixture/kibana.json | 2 +- .../plugins/kibana_cors_test/kibana.json | 2 +- .../plugins/iframe_embedded/kibana.json | 2 +- .../fixtures/plugins/alerts/kibana.json | 2 +- .../plugins/elasticsearch_client/kibana.json | 2 +- .../plugins/event_log/kibana.json | 2 +- .../plugins/feature_usage_test/kibana.json | 2 +- .../plugins/sample_task_plugin/kibana.json | 2 +- .../task_manager_performance/kibana.json | 2 +- .../plugins/resolver_test/kibana.json | 2 +- .../fixtures/oidc/oidc_provider/kibana.json | 2 +- .../fixtures/saml/saml_provider/kibana.json | 2 +- .../fixtures/plugins/foo_plugin/kibana.json | 2 +- .../stack_management_usage_test/kibana.json | 4 +- 42 files changed, 86 insertions(+), 100 deletions(-) diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts index 4dc912680ec631..f3a92c896b0148 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts @@ -9,12 +9,10 @@ import { mockReadFile } from './plugin_manifest_parser.test.mocks'; import { PluginDiscoveryErrorType } from './plugin_discovery_error'; -import { loggingSystemMock } from '../../logging/logging_system.mock'; import { resolve } from 'path'; import { parseManifest } from './plugin_manifest_parser'; -const logger = loggingSystemMock.createLogger(); const pluginPath = resolve('path', 'existent-dir'); const pluginManifestPath = resolve(pluginPath, 'kibana.json'); const packageInfo = { @@ -34,7 +32,7 @@ test('return error when manifest is empty', async () => { cb(null, Buffer.from('')); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Unexpected end of JSON input (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -46,7 +44,7 @@ test('return error when manifest content is null', async () => { cb(null, Buffer.from('null')); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin manifest must contain a JSON encoded object. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -58,7 +56,7 @@ test('return error when manifest content is not a valid JSON', async () => { cb(null, Buffer.from('not-json')); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Unexpected token o in JSON at position 1 (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -70,7 +68,7 @@ test('return error when plugin id is missing', async () => { cb(null, Buffer.from(JSON.stringify({ version: 'some-version' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin manifest must contain an "id" property. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -82,37 +80,24 @@ test('return error when plugin id includes `.` characters', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'some.name', version: 'some-version' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "id" must not include \`.\` characters. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, }); }); -test('logs warning if pluginId is not in camelCase format', async () => { +test('return error when pluginId is not in camelCase format', async () => { + expect.assertions(1); mockReadFile.mockImplementation((path, cb) => { cb(null, Buffer.from(JSON.stringify({ id: 'some_name', version: 'kibana', server: true }))); }); - expect(loggingSystemMock.collect(logger).warn).toHaveLength(0); - await parseManifest(pluginPath, packageInfo, logger); - expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` - Array [ - Array [ - "Expect plugin \\"id\\" in camelCase, but found: some_name", - ], - ] - `); -}); - -test('does not log pluginId format warning in dist mode', async () => { - mockReadFile.mockImplementation((path, cb) => { - cb(null, Buffer.from(JSON.stringify({ id: 'some_name', version: 'kibana', server: true }))); + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ + message: `Plugin "id" must be camelCase, but found: some_name. (invalid-manifest, ${pluginManifestPath})`, + type: PluginDiscoveryErrorType.InvalidManifest, + path: pluginManifestPath, }); - - expect(loggingSystemMock.collect(logger).warn).toHaveLength(0); - await parseManifest(pluginPath, { ...packageInfo, dist: true }, logger); - expect(loggingSystemMock.collect(logger).warn.length).toBe(0); }); test('return error when plugin version is missing', async () => { @@ -120,7 +105,7 @@ test('return error when plugin version is missing', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'someId' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin manifest for "someId" must contain a "version" property. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -132,7 +117,7 @@ test('return error when plugin expected Kibana version is lower than actual vers cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '6.4.2' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "someId" is only compatible with Kibana version "6.4.2", but used Kibana version is "7.0.0-alpha1". (incompatible-version, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.IncompatibleVersion, path: pluginManifestPath, @@ -147,7 +132,7 @@ test('return error when plugin expected Kibana version cannot be interpreted as ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "someId" is only compatible with Kibana version "non-sem-ver", but used Kibana version is "7.0.0-alpha1". (incompatible-version, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.IncompatibleVersion, path: pluginManifestPath, @@ -159,7 +144,7 @@ test('return error when plugin config path is not a string', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0', configPath: 2 }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `The "configPath" in plugin manifest for "someId" should either be a string or an array of strings. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -174,7 +159,7 @@ test('return error when plugin config path is an array that contains non-string ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `The "configPath" in plugin manifest for "someId" should either be a string or an array of strings. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -186,7 +171,7 @@ test('return error when plugin expected Kibana version is higher than actual ver cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.1' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "someId" is only compatible with Kibana version "7.0.1", but used Kibana version is "7.0.0-alpha1". (incompatible-version, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.IncompatibleVersion, path: pluginManifestPath, @@ -198,7 +183,7 @@ test('return error when both `server` and `ui` are set to `false` or missing', a cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Both "server" and "ui" are missing or set to "false" in plugin manifest for "someId", but at least one of these must be set to "true". (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -211,7 +196,7 @@ test('return error when both `server` and `ui` are set to `false` or missing', a ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Both "server" and "ui" are missing or set to "false" in plugin manifest for "someId", but at least one of these must be set to "true". (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -234,7 +219,7 @@ test('return error when manifest contains unrecognized properties', async () => ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Manifest for plugin "someId" contains the following unrecognized properties: unknownOne,unknownTwo. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -247,20 +232,20 @@ describe('configPath', () => { cb(null, Buffer.from(JSON.stringify({ id: 'plugin', version: '7.0.0', server: true }))); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toBe(manifest.id); }); test('falls back to plugin id in snakeCase format', async () => { mockReadFile.mockImplementation((path, cb) => { - cb(null, Buffer.from(JSON.stringify({ id: 'SomeId', version: '7.0.0', server: true }))); + cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0', server: true }))); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toBe('some_id'); }); - test('not formated to snakeCase if defined explicitly as string', async () => { + test('not formatted to snakeCase if defined explicitly as string', async () => { mockReadFile.mockImplementation((path, cb) => { cb( null, @@ -270,11 +255,11 @@ describe('configPath', () => { ); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toBe('somePath'); }); - test('not formated to snakeCase if defined explicitly as an array of strings', async () => { + test('not formatted to snakeCase if defined explicitly as an array of strings', async () => { mockReadFile.mockImplementation((path, cb) => { cb( null, @@ -284,7 +269,7 @@ describe('configPath', () => { ); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toEqual(['somePath']); }); }); @@ -294,7 +279,7 @@ test('set defaults for all missing optional fields', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0', server: true }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: 'some_id', version: '7.0.0', @@ -325,7 +310,7 @@ test('return all set optional fields as they are in manifest', async () => { ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: ['some', 'path'], version: 'some-version', @@ -355,7 +340,7 @@ test('return manifest when plugin expected Kibana version matches actual version ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: 'some-path', version: 'some-version', @@ -385,7 +370,7 @@ test('return manifest when plugin expected Kibana version is `kibana`', async () ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: 'some_id', version: 'some-version', diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.ts index 9db68bcaa4ccea..eae0e73e86c465 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.ts @@ -12,7 +12,6 @@ import { coerce } from 'semver'; import { promisify } from 'util'; import { snakeCase } from 'lodash'; import { isConfigPath, PackageInfo } from '../../config'; -import { Logger } from '../../logging'; import { PluginManifest } from '../types'; import { PluginDiscoveryError } from './plugin_discovery_error'; import { isCamelCase } from './is_camel_case'; @@ -63,8 +62,7 @@ const KNOWN_MANIFEST_FIELDS = (() => { */ export async function parseManifest( pluginPath: string, - packageInfo: PackageInfo, - log: Logger + packageInfo: PackageInfo ): Promise { const manifestPath = resolve(pluginPath, MANIFEST_FILE_NAME); @@ -105,8 +103,11 @@ export async function parseManifest( ); } - if (!packageInfo.dist && !isCamelCase(manifest.id)) { - log.warn(`Expect plugin "id" in camelCase, but found: ${manifest.id}`); + if (!isCamelCase(manifest.id)) { + throw PluginDiscoveryError.invalidManifest( + manifestPath, + new Error(`Plugin "id" must be camelCase, but found: ${manifest.id}.`) + ); } if (!manifest.version || typeof manifest.version !== 'string') { diff --git a/src/core/server/plugins/discovery/plugins_discovery.ts b/src/core/server/plugins/discovery/plugins_discovery.ts index 61eccff982593b..368795968a7cbc 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.ts @@ -179,7 +179,7 @@ function createPlugin$( coreContext: CoreContext, instanceInfo: InstanceInfo ) { - return from(parseManifest(path, coreContext.env.packageInfo, log)).pipe( + return from(parseManifest(path, coreContext.env.packageInfo)).pipe( map((manifest) => { log.debug(`Successfully discovered plugin "${manifest.id}" at "${path}"`); const opaqueId = Symbol(manifest.id); diff --git a/test/common/fixtures/plugins/newsfeed/kibana.json b/test/common/fixtures/plugins/newsfeed/kibana.json index 110b53fc6b2e92..0fbd24f45b6846 100644 --- a/test/common/fixtures/plugins/newsfeed/kibana.json +++ b/test/common/fixtures/plugins/newsfeed/kibana.json @@ -1,5 +1,5 @@ { - "id": "newsfeed-fixtures", + "id": "newsfeedFixtures", "version": "kibana", "server": true, "ui": false diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json index 084cee2fddf084..2fd2a9e5144d44 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json @@ -1,5 +1,5 @@ { - "id": "kbn_tp_run_pipeline", + "id": "kbnTpRunPipeline", "version": "0.0.1", "kibanaVersion": "kibana", "requiredPlugins": [ diff --git a/test/plugin_functional/plugins/app_link_test/kibana.json b/test/plugin_functional/plugins/app_link_test/kibana.json index 5384d4fee1508c..c37eae274460c7 100644 --- a/test/plugin_functional/plugins/app_link_test/kibana.json +++ b/test/plugin_functional/plugins/app_link_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "app_link_test", + "id": "appLinkTest", "version": "0.0.1", "kibanaVersion": "kibana", "server": false, diff --git a/test/plugin_functional/plugins/core_app_status/kibana.json b/test/plugin_functional/plugins/core_app_status/kibana.json index 91d8e6fd8f9e18..eb825cf9990c9e 100644 --- a/test/plugin_functional/plugins/core_app_status/kibana.json +++ b/test/plugin_functional/plugins/core_app_status/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_app_status", + "id": "coreAppStatus", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_app_status"], diff --git a/test/plugin_functional/plugins/core_plugin_a/kibana.json b/test/plugin_functional/plugins/core_plugin_a/kibana.json index 0989595c49a58c..9a153011bdc707 100644 --- a/test/plugin_functional/plugins/core_plugin_a/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_a/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_plugin_a", + "id": "corePluginA", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_a"], diff --git a/test/plugin_functional/plugins/core_plugin_appleave/kibana.json b/test/plugin_functional/plugins/core_plugin_appleave/kibana.json index 95343cbcf28045..f9337fcc226f26 100644 --- a/test/plugin_functional/plugins/core_plugin_appleave/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_appleave/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_plugin_appleave", + "id": "corePluginAppleave", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_appleave"], diff --git a/test/plugin_functional/plugins/core_plugin_b/kibana.json b/test/plugin_functional/plugins/core_plugin_b/kibana.json index 7c6aa597c82faa..d132e714ea31de 100644 --- a/test/plugin_functional/plugins/core_plugin_b/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_b/kibana.json @@ -1,10 +1,10 @@ { - "id": "core_plugin_b", + "id": "corePluginB", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_b"], "server": true, "ui": true, - "requiredPlugins": ["core_plugin_a"], - "optionalPlugins": ["core_plugin_c"] + "requiredPlugins": ["corePluginA"], + "optionalPlugins": ["corePluginC"] } diff --git a/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx b/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx index 48c8d85b21dac9..5bab0275439df1 100644 --- a/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx +++ b/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx @@ -16,7 +16,7 @@ declare global { } export interface CorePluginBDeps { - core_plugin_a: CorePluginAPluginSetup; + corePluginA: CorePluginAPluginSetup; } export class CorePluginBPlugin @@ -37,7 +37,7 @@ export class CorePluginBPlugin return { sayHi() { - return `Plugin A said: ${deps.core_plugin_a.getGreeting()}`; + return `Plugin A said: ${deps.corePluginA.getGreeting()}`; }, }; } diff --git a/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json b/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json index a8a5616627726d..61863781b8f324 100644 --- a/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_chromeless/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_plugin_chromeless", + "id": "corePluginChromeless", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_chromeless"], diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json b/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json index 984b96a8bcba1e..1b0f477ef34aef 100644 --- a/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_plugin_helpmenu", + "id": "corePluginHelpmenu", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_helpmenu"], diff --git a/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json b/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json index 6fbddad22b7641..000f8e38a1035d 100644 --- a/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json +++ b/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json @@ -1,5 +1,5 @@ { - "id": "core_plugin_route_timeouts", + "id": "corePluginRouteTimeouts", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["core_plugin_route_timeouts"], diff --git a/test/plugin_functional/plugins/core_provider_plugin/kibana.json b/test/plugin_functional/plugins/core_provider_plugin/kibana.json index 8d9b30acab8935..c55f62762e233d 100644 --- a/test/plugin_functional/plugins/core_provider_plugin/kibana.json +++ b/test/plugin_functional/plugins/core_provider_plugin/kibana.json @@ -1,8 +1,8 @@ { - "id": "core_provider_plugin", + "id": "coreProviderPlugin", "version": "0.0.1", "kibanaVersion": "kibana", - "optionalPlugins": ["core_plugin_a", "core_plugin_b", "licensing", "globalSearchTest"], + "optionalPlugins": ["corePluginA", "corePluginB", "licensing", "globalSearchTest"], "server": false, "ui": true } diff --git a/test/plugin_functional/plugins/data_search/kibana.json b/test/plugin_functional/plugins/data_search/kibana.json index 3acbe9f97d8f0c..28f7eb9996fc5d 100644 --- a/test/plugin_functional/plugins/data_search/kibana.json +++ b/test/plugin_functional/plugins/data_search/kibana.json @@ -1,5 +1,5 @@ { - "id": "data_search_plugin", + "id": "dataSearchPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["data_search_test_plugin"], diff --git a/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json b/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json index a7674881e8ba02..3d934414adc2f1 100644 --- a/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json +++ b/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "elasticsearch_client_plugin", + "id": "elasticsearchClientPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "server": true, diff --git a/test/plugin_functional/plugins/index_patterns/kibana.json b/test/plugin_functional/plugins/index_patterns/kibana.json index e098950dc96775..3b41fa5124a452 100644 --- a/test/plugin_functional/plugins/index_patterns/kibana.json +++ b/test/plugin_functional/plugins/index_patterns/kibana.json @@ -1,5 +1,5 @@ { - "id": "index_patterns_test_plugin", + "id": "indexPatternsTestPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["index_patterns_test_plugin"], diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json b/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json index 08ce182aa02933..51a254016b6500 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json @@ -1,5 +1,5 @@ { - "id": "kbn_sample_panel_action", + "id": "kbnSamplePanelAction", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["kbn_sample_panel_action"], diff --git a/test/plugin_functional/plugins/kbn_top_nav/kibana.json b/test/plugin_functional/plugins/kbn_top_nav/kibana.json index b274e80b9ef65b..a656eae476b874 100644 --- a/test/plugin_functional/plugins/kbn_top_nav/kibana.json +++ b/test/plugin_functional/plugins/kbn_top_nav/kibana.json @@ -1,9 +1,9 @@ { - "id": "kbn_top_nav", + "id": "kbnTopNav", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["kbn_top_nav"], "server": false, "ui": true, "requiredPlugins": ["navigation"] -} \ No newline at end of file +} diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/kibana.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/kibana.json index 33c8f3238dc477..3e2d1c9e98fee6 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/kibana.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/kibana.json @@ -1,5 +1,5 @@ { - "id": "kbn_tp_custom_visualizations", + "id": "kbnTpCustomVisualizations", "version": "0.0.1", "kibanaVersion": "kibana", "requiredPlugins": [ diff --git a/test/plugin_functional/plugins/management_test_plugin/kibana.json b/test/plugin_functional/plugins/management_test_plugin/kibana.json index e52b60b3a4e313..f07c2ae997221e 100644 --- a/test/plugin_functional/plugins/management_test_plugin/kibana.json +++ b/test/plugin_functional/plugins/management_test_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "management_test_plugin", + "id": "managementTestPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["management_test_plugin"], diff --git a/test/plugin_functional/plugins/rendering_plugin/kibana.json b/test/plugin_functional/plugins/rendering_plugin/kibana.json index 886eca2bdde1d8..f5f218db3c1846 100644 --- a/test/plugin_functional/plugins/rendering_plugin/kibana.json +++ b/test/plugin_functional/plugins/rendering_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "rendering_plugin", + "id": "renderingPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["rendering_plugin"], diff --git a/test/plugin_functional/plugins/session_notifications/kibana.json b/test/plugin_functional/plugins/session_notifications/kibana.json index 0b80b531d2f840..939a96e3f21d64 100644 --- a/test/plugin_functional/plugins/session_notifications/kibana.json +++ b/test/plugin_functional/plugins/session_notifications/kibana.json @@ -1,9 +1,9 @@ { - "id": "session_notifications", + "id": "sessionNotifications", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["session_notifications"], "server": false, "ui": true, "requiredPlugins": ["data", "navigation"] -} \ No newline at end of file +} diff --git a/test/plugin_functional/plugins/ui_settings_plugin/kibana.json b/test/plugin_functional/plugins/ui_settings_plugin/kibana.json index 35e4c35490e2fb..459d995333eca8 100644 --- a/test/plugin_functional/plugins/ui_settings_plugin/kibana.json +++ b/test/plugin_functional/plugins/ui_settings_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "ui_settings_plugin", + "id": "uiSettingsPlugin", "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["ui_settings_plugin"], diff --git a/test/plugin_functional/test_suites/core_plugins/ui_plugins.ts b/test/plugin_functional/test_suites/core_plugins/ui_plugins.ts index 4015b8959ece64..1d6b33e41b7724 100644 --- a/test/plugin_functional/test_suites/core_plugins/ui_plugins.ts +++ b/test/plugin_functional/test_suites/core_plugins/ui_plugins.ts @@ -24,7 +24,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide it('should run the new platform plugins', async () => { expect( await browser.execute(() => { - return window._coreProvider.setup.plugins.core_plugin_b.sayHi(); + return window._coreProvider.setup.plugins.corePluginB.sayHi(); }) ).to.be('Plugin A said: Hello from Plugin A!'); }); @@ -65,7 +65,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide it('should send kbn-system-request header when asSystemRequest: true', async () => { expect( await browser.executeAsync(async (cb) => { - window._coreProvider.start.plugins.core_plugin_b.sendSystemRequest(true).then(cb); + window._coreProvider.start.plugins.corePluginB.sendSystemRequest(true).then(cb); }) ).to.be('/core_plugin_b/system_request says: "System request? true"'); }); @@ -73,7 +73,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide it('should not send kbn-system-request header when asSystemRequest: false', async () => { expect( await browser.executeAsync(async (cb) => { - window._coreProvider.start.plugins.core_plugin_b.sendSystemRequest(false).then(cb); + window._coreProvider.start.plugins.corePluginB.sendSystemRequest(false).then(cb); }) ).to.be('/core_plugin_b/system_request says: "System request? false"'); }); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json index 9a7bedbb5c6d5c..6a43c7c74ad8c2 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json @@ -1,5 +1,5 @@ { - "id": "aad-fixtures", + "id": "aadFixtures", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json index 5f92b9e5479e84..f63d6ef0d45acd 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json @@ -1,5 +1,5 @@ { - "id": "actions_simulators", + "id": "actionsSimulators", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json index 8f606276998f58..2f8117163471d4 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json @@ -1,5 +1,5 @@ { - "id": "task_manager_fixture", + "id": "taskManagerFixture", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json b/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json index 9c94f2006b7f87..a0ebde9bff4b79 100644 --- a/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json +++ b/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "kibana_cors_test", + "id": "kibanaCorsTest", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["test", "cors"], diff --git a/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json b/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json index ea9f55bd21c6ea..919b7f69d28b93 100644 --- a/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json +++ b/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json @@ -1,5 +1,5 @@ { - "id": "iframe_embedded", + "id": "iframeEmbedded", "version": "1.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json index 784a766e608bc6..11a8fb977cd78b 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json @@ -1,5 +1,5 @@ { - "id": "alerting_fixture", + "id": "alertingFixture", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json index 37ec33c168e763..5f4cb3f7f7eb2f 100644 --- a/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json @@ -1,5 +1,5 @@ { - "id": "elasticsearch_client_xpack", + "id": "elasticsearchClientXpack", "version": "1.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json b/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json index 4b467ce9750122..4c940ffec14637 100644 --- a/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json @@ -1,5 +1,5 @@ { - "id": "event_log_fixture", + "id": "eventLogFixture", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json index b11b7ada24a578..b81f96362e9f5f 100644 --- a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "feature_usage_test", + "id": "featureUsageTest", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "feature_usage_test"], diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json index 416ef7fa345919..6a8a2221b48d30 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "sample_task_plugin", + "id": "sampleTaskPlugin", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json index 1fa480cd53c483..387f392c8db984 100644 --- a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json +++ b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json @@ -1,5 +1,5 @@ { - "id": "task_manager_performance", + "id": "taskManagerPerformance", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json b/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json index 499983561e89d3..a203705e13ed67 100644 --- a/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json +++ b/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "resolver_test", + "id": "resolverTest", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "resolverTest"], diff --git a/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json b/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json index faaa0b9165828d..aa7cd499a173af 100644 --- a/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json +++ b/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json @@ -1,5 +1,5 @@ { - "id": "oidc_provider_plugin", + "id": "oidcProviderPlugin", "version": "8.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json index 3cbd37e38bb2da..81ec23fc3d2f3a 100644 --- a/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json +++ b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json @@ -1,5 +1,5 @@ { - "id": "saml_provider_plugin", + "id": "samlProviderPlugin", "version": "8.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json index cec1640fbb047b..912cf5d70e16b5 100644 --- a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json +++ b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "foo_plugin", + "id": "fooPlugin", "version": "1.0.0", "kibanaVersion": "kibana", "requiredPlugins": ["features"], diff --git a/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json b/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json index b586de3fa4d793..c41fe744ca9463 100644 --- a/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json +++ b/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json @@ -1,8 +1,8 @@ { - "id": "StackManagementUsageTest", + "id": "stackManagementUsageTest", "version": "1.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "StackManagementUsageTest"], + "configPath": ["xpack", "stackManagementUsageTest"], "requiredPlugins": [], "server": false, "ui": true From 619db365912b752552edc2f97995529e2cc26328 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Thu, 11 Feb 2021 14:46:14 +0000 Subject: [PATCH 24/24] [Task manager] Adds support for limited concurrency tasks (#90365) Adds support for limited concurrency on a Task Type. --- x-pack/plugins/task_manager/README.md | 8 +- .../server/buffered_task_store.test.ts | 10 +- .../server/buffered_task_store.ts | 4 - .../task_manager/server/lib/fill_pool.test.ts | 56 +- .../task_manager/server/lib/fill_pool.ts | 132 +- .../monitoring/task_run_statistics.test.ts | 1 + .../server/monitoring/task_run_statistics.ts | 56 +- .../task_manager/server/plugin.test.ts | 9 + x-pack/plugins/task_manager/server/plugin.ts | 10 +- .../polling/delay_on_claim_conflicts.test.ts | 61 + .../polling/delay_on_claim_conflicts.ts | 12 +- .../server/polling_lifecycle.test.ts | 151 +- .../task_manager/server/polling_lifecycle.ts | 126 +- .../mark_available_tasks_as_claimed.test.ts | 97 +- .../mark_available_tasks_as_claimed.ts | 70 +- .../server/queries/task_claiming.mock.ts | 33 + .../server/queries/task_claiming.test.ts | 1516 +++++++++++++ .../server/queries/task_claiming.ts | 488 +++++ x-pack/plugins/task_manager/server/task.ts | 10 + .../task_manager/server/task_events.ts | 16 +- .../task_manager/server/task_pool.test.ts | 2 + .../plugins/task_manager/server/task_pool.ts | 54 +- .../server/task_running/task_runner.test.ts | 1915 +++++++++-------- .../server/task_running/task_runner.ts | 191 +- .../server/task_scheduling.test.ts | 105 +- .../task_manager/server/task_scheduling.ts | 29 +- .../task_manager/server/task_store.mock.ts | 17 +- .../task_manager/server/task_store.test.ts | 1098 +--------- .../plugins/task_manager/server/task_store.ts | 240 +-- .../server/task_type_dictionary.ts | 4 + .../sample_task_plugin/server/init_routes.ts | 10 +- .../sample_task_plugin/server/plugin.ts | 14 + .../test_suites/task_manager/health_route.ts | 15 +- .../task_manager/task_management.ts | 207 +- 34 files changed, 4163 insertions(+), 2604 deletions(-) create mode 100644 x-pack/plugins/task_manager/server/queries/task_claiming.mock.ts create mode 100644 x-pack/plugins/task_manager/server/queries/task_claiming.test.ts create mode 100644 x-pack/plugins/task_manager/server/queries/task_claiming.ts diff --git a/x-pack/plugins/task_manager/README.md b/x-pack/plugins/task_manager/README.md index 9be3be14ea3fca..c20bc4b29bcc84 100644 --- a/x-pack/plugins/task_manager/README.md +++ b/x-pack/plugins/task_manager/README.md @@ -85,10 +85,10 @@ export class Plugin { // This defaults to what is configured at the task manager level. maxAttempts: 5, - // The clusterMonitoring task occupies 2 workers, so if the system has 10 worker slots, - // 5 clusterMonitoring tasks could run concurrently per Kibana instance. This value is - // overridden by the `override_num_workers` config value, if specified. - numWorkers: 2, + // The maximum number tasks of this type that can be run concurrently per Kibana instance. + // Setting this value will force Task Manager to poll for this task type seperatly from other task types which + // can add significant load to the ES cluster, so please use this configuration only when absolutly necesery. + maxConcurrency: 1, // The createTaskRunner function / method returns an object that is responsible for // performing the work of the task. context: { taskInstance }, is documented below. diff --git a/x-pack/plugins/task_manager/server/buffered_task_store.test.ts b/x-pack/plugins/task_manager/server/buffered_task_store.test.ts index 70d24b235d8805..45607713a31287 100644 --- a/x-pack/plugins/task_manager/server/buffered_task_store.test.ts +++ b/x-pack/plugins/task_manager/server/buffered_task_store.test.ts @@ -13,19 +13,17 @@ import { TaskStatus } from './task'; describe('Buffered Task Store', () => { test('proxies the TaskStore for `maxAttempts` and `remove`', async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); taskStore.bulkUpdate.mockResolvedValue([]); const bufferedStore = new BufferedTaskStore(taskStore, {}); - expect(bufferedStore.maxAttempts).toEqual(10); - bufferedStore.remove('1'); expect(taskStore.remove).toHaveBeenCalledWith('1'); }); describe('update', () => { test("proxies the TaskStore's `bulkUpdate`", async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); const bufferedStore = new BufferedTaskStore(taskStore, {}); const task = mockTask(); @@ -37,7 +35,7 @@ describe('Buffered Task Store', () => { }); test('handles partially successfull bulkUpdates resolving each call appropriately', async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); const bufferedStore = new BufferedTaskStore(taskStore, {}); const tasks = [mockTask(), mockTask(), mockTask()]; @@ -61,7 +59,7 @@ describe('Buffered Task Store', () => { }); test('handles multiple items with the same id', async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); const bufferedStore = new BufferedTaskStore(taskStore, {}); const duplicateIdTask = mockTask(); diff --git a/x-pack/plugins/task_manager/server/buffered_task_store.ts b/x-pack/plugins/task_manager/server/buffered_task_store.ts index 4e4a533303867f..ca735dd6f36389 100644 --- a/x-pack/plugins/task_manager/server/buffered_task_store.ts +++ b/x-pack/plugins/task_manager/server/buffered_task_store.ts @@ -26,10 +26,6 @@ export class BufferedTaskStore implements Updatable { ); } - public get maxAttempts(): number { - return this.taskStore.maxAttempts; - } - public async update(doc: ConcreteTaskInstance): Promise { return unwrapPromise(this.bufferedUpdate(doc)); } diff --git a/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts index 79a0d2f6900429..8e0396a453b3d9 100644 --- a/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts +++ b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts @@ -10,27 +10,32 @@ import sinon from 'sinon'; import { fillPool, FillPoolResult } from './fill_pool'; import { TaskPoolRunResult } from '../task_pool'; import { asOk, Result } from './result_type'; -import { ClaimOwnershipResult } from '../task_store'; import { ConcreteTaskInstance, TaskStatus } from '../task'; import { TaskManagerRunner } from '../task_running/task_runner'; +import { from, Observable } from 'rxjs'; +import { ClaimOwnershipResult } from '../queries/task_claiming'; jest.mock('../task_running/task_runner'); describe('fillPool', () => { function mockFetchAvailableTasks( tasksToMock: number[][] - ): () => Promise> { - const tasks: ConcreteTaskInstance[][] = tasksToMock.map((ids) => mockTaskInstances(ids)); - let index = 0; - return async () => - asOk({ - stats: { - tasksUpdated: tasks[index + 1]?.length ?? 0, - tasksConflicted: 0, - tasksClaimed: 0, - }, - docs: tasks[index++] || [], - }); + ): () => Observable> { + const claimCycles: ConcreteTaskInstance[][] = tasksToMock.map((ids) => mockTaskInstances(ids)); + return () => + from( + claimCycles.map((tasks) => + asOk({ + stats: { + tasksUpdated: tasks?.length ?? 0, + tasksConflicted: 0, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: tasks, + }) + ) + ); } const mockTaskInstances = (ids: number[]): ConcreteTaskInstance[] => @@ -51,7 +56,7 @@ describe('fillPool', () => { ownerId: null, })); - test('stops filling when pool runs all claimed tasks, even if there is more capacity', async () => { + test('fills task pool with all claimed tasks until fetchAvailableTasks stream closes', async () => { const tasks = [ [1, 2, 3], [4, 5], @@ -62,21 +67,7 @@ describe('fillPool', () => { await fillPool(fetchAvailableTasks, converter, run); - expect(_.flattenDeep(run.args)).toEqual(mockTaskInstances([1, 2, 3])); - }); - - test('stops filling when the pool has no more capacity', async () => { - const tasks = [ - [1, 2, 3], - [4, 5], - ]; - const fetchAvailableTasks = mockFetchAvailableTasks(tasks); - const run = sinon.spy(async () => TaskPoolRunResult.RanOutOfCapacity); - const converter = _.identity; - - await fillPool(fetchAvailableTasks, converter, run); - - expect(_.flattenDeep(run.args)).toEqual(mockTaskInstances([1, 2, 3])); + expect(_.flattenDeep(run.args)).toEqual(mockTaskInstances([1, 2, 3, 4, 5])); }); test('calls the converter on the records prior to running', async () => { @@ -91,7 +82,7 @@ describe('fillPool', () => { await fillPool(fetchAvailableTasks, converter, run); - expect(_.flattenDeep(run.args)).toEqual(['1', '2', '3']); + expect(_.flattenDeep(run.args)).toEqual(['1', '2', '3', '4', '5']); }); describe('error handling', () => { @@ -101,7 +92,10 @@ describe('fillPool', () => { (instance.id as unknown) as TaskManagerRunner; try { - const fetchAvailableTasks = async () => Promise.reject('fetch is not working'); + const fetchAvailableTasks = () => + new Observable>((obs) => + obs.error('fetch is not working') + ); await fillPool(fetchAvailableTasks, converter, run); } catch (err) { diff --git a/x-pack/plugins/task_manager/server/lib/fill_pool.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.ts index 45a33081bde51e..c9050ebb75d69f 100644 --- a/x-pack/plugins/task_manager/server/lib/fill_pool.ts +++ b/x-pack/plugins/task_manager/server/lib/fill_pool.ts @@ -6,12 +6,14 @@ */ import { performance } from 'perf_hooks'; +import { Observable } from 'rxjs'; +import { concatMap, last } from 'rxjs/operators'; +import { ClaimOwnershipResult } from '../queries/task_claiming'; import { ConcreteTaskInstance } from '../task'; import { WithTaskTiming, startTaskTimer } from '../task_events'; import { TaskPoolRunResult } from '../task_pool'; import { TaskManagerRunner } from '../task_running'; -import { ClaimOwnershipResult } from '../task_store'; -import { Result, map } from './result_type'; +import { Result, map as mapResult, asErr, asOk } from './result_type'; export enum FillPoolResult { Failed = 'Failed', @@ -22,6 +24,17 @@ export enum FillPoolResult { PoolFilled = 'PoolFilled', } +type FillPoolAndRunResult = Result< + { + result: TaskPoolRunResult; + stats?: ClaimOwnershipResult['stats']; + }, + { + result: FillPoolResult; + stats?: ClaimOwnershipResult['stats']; + } +>; + export type ClaimAndFillPoolResult = Partial> & { result: FillPoolResult; }; @@ -40,52 +53,81 @@ export type TimedFillPoolResult = WithTaskTiming; * @param converter - a function that converts task records to the appropriate task runner */ export async function fillPool( - fetchAvailableTasks: () => Promise>, + fetchAvailableTasks: () => Observable>, converter: (taskInstance: ConcreteTaskInstance) => TaskManagerRunner, run: (tasks: TaskManagerRunner[]) => Promise ): Promise { performance.mark('fillPool.start'); - const stopTaskTimer = startTaskTimer(); - const augmentTimingTo = ( - result: FillPoolResult, - stats?: ClaimOwnershipResult['stats'] - ): TimedFillPoolResult => ({ - result, - stats, - timing: stopTaskTimer(), - }); - return map>( - await fetchAvailableTasks(), - async ({ docs, stats }) => { - if (!docs.length) { - performance.mark('fillPool.bailNoTasks'); - performance.measure( - 'fillPool.activityDurationUntilNoTasks', - 'fillPool.start', - 'fillPool.bailNoTasks' - ); - return augmentTimingTo(FillPoolResult.NoTasksClaimed, stats); - } - - const tasks = docs.map(converter); - - switch (await run(tasks)) { - case TaskPoolRunResult.RanOutOfCapacity: - performance.mark('fillPool.bailExhaustedCapacity'); - performance.measure( - 'fillPool.activityDurationUntilExhaustedCapacity', - 'fillPool.start', - 'fillPool.bailExhaustedCapacity' + return new Promise((resolve, reject) => { + const stopTaskTimer = startTaskTimer(); + const augmentTimingTo = ( + result: FillPoolResult, + stats?: ClaimOwnershipResult['stats'] + ): TimedFillPoolResult => ({ + result, + stats, + timing: stopTaskTimer(), + }); + fetchAvailableTasks() + .pipe( + // each ClaimOwnershipResult will be sequencially consumed an ran using the `run` handler + concatMap(async (res) => + mapResult>( + res, + async ({ docs, stats }) => { + if (!docs.length) { + performance.mark('fillPool.bailNoTasks'); + performance.measure( + 'fillPool.activityDurationUntilNoTasks', + 'fillPool.start', + 'fillPool.bailNoTasks' + ); + return asOk({ result: TaskPoolRunResult.NoTaskWereRan, stats }); + } + return asOk( + await run(docs.map(converter)).then((runResult) => ({ + result: runResult, + stats, + })) + ); + }, + async (fillPoolResult) => asErr({ result: fillPoolResult }) + ) + ), + // when the final call to `run` completes, we'll complete the stream and emit the + // final accumulated result + last() + ) + .subscribe( + (claimResults) => { + resolve( + mapResult( + claimResults, + ({ result, stats }) => { + switch (result) { + case TaskPoolRunResult.RanOutOfCapacity: + performance.mark('fillPool.bailExhaustedCapacity'); + performance.measure( + 'fillPool.activityDurationUntilExhaustedCapacity', + 'fillPool.start', + 'fillPool.bailExhaustedCapacity' + ); + return augmentTimingTo(FillPoolResult.RanOutOfCapacity, stats); + case TaskPoolRunResult.RunningAtCapacity: + performance.mark('fillPool.cycle'); + return augmentTimingTo(FillPoolResult.RunningAtCapacity, stats); + case TaskPoolRunResult.NoTaskWereRan: + return augmentTimingTo(FillPoolResult.NoTasksClaimed, stats); + default: + performance.mark('fillPool.cycle'); + return augmentTimingTo(FillPoolResult.PoolFilled, stats); + } + }, + ({ result, stats }) => augmentTimingTo(result, stats) + ) ); - return augmentTimingTo(FillPoolResult.RanOutOfCapacity, stats); - case TaskPoolRunResult.RunningAtCapacity: - performance.mark('fillPool.cycle'); - return augmentTimingTo(FillPoolResult.RunningAtCapacity, stats); - default: - performance.mark('fillPool.cycle'); - return augmentTimingTo(FillPoolResult.PoolFilled, stats); - } - }, - async (result) => augmentTimingTo(result) - ); + }, + (err) => reject(err) + ); + }); } diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts index 5c32c3e7225c43..7040d5acd4eaf3 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts @@ -537,6 +537,7 @@ describe('Task Run Statistics', () => { asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed, timing })) ); events$.next(asTaskManagerStatEvent('pollingDelay', asOk(0))); + events$.next(asTaskManagerStatEvent('claimDuration', asOk(10))); events$.next( asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed, timing })) ); diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts index 4b7bdf595f1f55..3185d3c449c32c 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts @@ -19,6 +19,7 @@ import { RanTask, TaskTiming, isTaskManagerStatEvent, + TaskManagerStat, } from '../task_events'; import { isOk, Ok, unwrap } from '../lib/result_type'; import { ConcreteTaskInstance } from '../task'; @@ -39,6 +40,7 @@ interface FillPoolStat extends JsonObject { last_successful_poll: string; last_polling_delay: string; duration: number[]; + claim_duration: number[]; claim_conflicts: number[]; claim_mismatches: number[]; result_frequency_percent_as_number: FillPoolResult[]; @@ -51,6 +53,7 @@ interface ExecutionStat extends JsonObject { export interface TaskRunStat extends JsonObject { drift: number[]; + drift_by_type: Record; load: number[]; execution: ExecutionStat; polling: Omit & @@ -125,6 +128,7 @@ export function createTaskRunAggregator( const resultFrequencyQueue = createRunningAveragedStat(runningAverageWindowSize); const pollingDurationQueue = createRunningAveragedStat(runningAverageWindowSize); + const claimDurationQueue = createRunningAveragedStat(runningAverageWindowSize); const claimConflictsQueue = createRunningAveragedStat(runningAverageWindowSize); const claimMismatchesQueue = createRunningAveragedStat(runningAverageWindowSize); const taskPollingEvents$: Observable> = combineLatest([ @@ -168,10 +172,26 @@ export function createTaskRunAggregator( ), map(() => new Date().toISOString()) ), + // get duration of task claim stage in polling + taskPollingLifecycle.events.pipe( + filter( + (taskEvent: TaskLifecycleEvent) => + isTaskManagerStatEvent(taskEvent) && + taskEvent.id === 'claimDuration' && + isOk(taskEvent.event) + ), + map((claimDurationEvent) => { + const duration = ((claimDurationEvent as TaskManagerStat).event as Ok).value; + return { + claimDuration: duration ? claimDurationQueue(duration) : claimDurationQueue(), + }; + }) + ), ]).pipe( - map(([{ polling }, pollingDelay]) => ({ + map(([{ polling }, pollingDelay, { claimDuration }]) => ({ polling: { last_polling_delay: pollingDelay, + claim_duration: claimDuration, ...polling, }, })) @@ -179,13 +199,18 @@ export function createTaskRunAggregator( return combineLatest([ taskRunEvents$.pipe( - startWith({ drift: [], execution: { duration: {}, result_frequency_percent_as_number: {} } }) + startWith({ + drift: [], + drift_by_type: {}, + execution: { duration: {}, result_frequency_percent_as_number: {} }, + }) ), taskManagerLoadStatEvents$.pipe(startWith({ load: [] })), taskPollingEvents$.pipe( startWith({ polling: { duration: [], + claim_duration: [], claim_conflicts: [], claim_mismatches: [], result_frequency_percent_as_number: [], @@ -218,6 +243,7 @@ function hasTiming(taskEvent: TaskLifecycleEvent) { function createTaskRunEventToStat(runningAverageWindowSize: number) { const driftQueue = createRunningAveragedStat(runningAverageWindowSize); + const driftByTaskQueue = createMapOfRunningAveragedStats(runningAverageWindowSize); const taskRunDurationQueue = createMapOfRunningAveragedStats(runningAverageWindowSize); const resultFrequencyQueue = createMapOfRunningAveragedStats( runningAverageWindowSize @@ -226,13 +252,17 @@ function createTaskRunEventToStat(runningAverageWindowSize: number) { task: ConcreteTaskInstance, timing: TaskTiming, result: TaskRunResult - ): Omit => ({ - drift: driftQueue(timing!.start - task.runAt.getTime()), - execution: { - duration: taskRunDurationQueue(task.taskType, timing!.stop - timing!.start), - result_frequency_percent_as_number: resultFrequencyQueue(task.taskType, result), - }, - }); + ): Omit => { + const drift = timing!.start - task.runAt.getTime(); + return { + drift: driftQueue(drift), + drift_by_type: driftByTaskQueue(task.taskType, drift), + execution: { + duration: taskRunDurationQueue(task.taskType, timing!.stop - timing!.start), + result_frequency_percent_as_number: resultFrequencyQueue(task.taskType, result), + }, + }; + }; } const DEFAULT_TASK_RUN_FREQUENCIES = { @@ -258,11 +288,15 @@ export function summarizeTaskRunStat( // eslint-disable-next-line @typescript-eslint/naming-convention last_polling_delay, duration: pollingDuration, + // eslint-disable-next-line @typescript-eslint/naming-convention + claim_duration, result_frequency_percent_as_number: pollingResultFrequency, claim_conflicts: claimConflicts, claim_mismatches: claimMismatches, }, drift, + // eslint-disable-next-line @typescript-eslint/naming-convention + drift_by_type, load, execution: { duration, result_frequency_percent_as_number: executionResultFrequency }, }: TaskRunStat, @@ -273,6 +307,9 @@ export function summarizeTaskRunStat( polling: { ...(last_successful_poll ? { last_successful_poll } : {}), ...(last_polling_delay ? { last_polling_delay } : {}), + ...(claim_duration + ? { claim_duration: calculateRunningAverage(claim_duration as number[]) } + : {}), duration: calculateRunningAverage(pollingDuration as number[]), claim_conflicts: calculateRunningAverage(claimConflicts as number[]), claim_mismatches: calculateRunningAverage(claimMismatches as number[]), @@ -282,6 +319,7 @@ export function summarizeTaskRunStat( }, }, drift: calculateRunningAverage(drift), + drift_by_type: mapValues(drift_by_type, (typedDrift) => calculateRunningAverage(typedDrift)), load: calculateRunningAverage(load), execution: { duration: mapValues(duration, (typedDurations) => calculateRunningAverage(typedDurations)), diff --git a/x-pack/plugins/task_manager/server/plugin.test.ts b/x-pack/plugins/task_manager/server/plugin.test.ts index 0a879ce92cba6e..45db18a3e83857 100644 --- a/x-pack/plugins/task_manager/server/plugin.test.ts +++ b/x-pack/plugins/task_manager/server/plugin.test.ts @@ -70,6 +70,15 @@ describe('TaskManagerPlugin', () => { const setupApi = await taskManagerPlugin.setup(coreMock.createSetup()); + // we only start a poller if we have task types that we support and we track + // phases (moving from Setup to Start) based on whether the poller is working + setupApi.registerTaskDefinitions({ + setupTimeType: { + title: 'setupTimeType', + createTaskRunner: () => ({ async run() {} }), + }, + }); + await taskManagerPlugin.start(coreMock.createStart()); expect(() => diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index 149d111b08f02a..507a021214a904 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -16,13 +16,12 @@ import { ServiceStatusLevels, CoreStatus, } from '../../../../src/core/server'; -import { TaskDefinition } from './task'; import { TaskPollingLifecycle } from './polling_lifecycle'; import { TaskManagerConfig } from './config'; import { createInitialMiddleware, addMiddlewareToChain, Middleware } from './lib/middleware'; import { removeIfExists } from './lib/remove_if_exists'; import { setupSavedObjects } from './saved_objects'; -import { TaskTypeDictionary } from './task_type_dictionary'; +import { TaskDefinitionRegistry, TaskTypeDictionary } from './task_type_dictionary'; import { FetchResult, SearchOpts, TaskStore } from './task_store'; import { createManagedConfiguration } from './lib/create_managed_configuration'; import { TaskScheduling } from './task_scheduling'; @@ -100,7 +99,7 @@ export class TaskManagerPlugin this.assertStillInSetup('add Middleware'); this.middleware = addMiddlewareToChain(this.middleware, middleware); }, - registerTaskDefinitions: (taskDefinition: Record) => { + registerTaskDefinitions: (taskDefinition: TaskDefinitionRegistry) => { this.assertStillInSetup('register task definitions'); this.definitions.registerTaskDefinitions(taskDefinition); }, @@ -110,12 +109,12 @@ export class TaskManagerPlugin public start({ savedObjects, elasticsearch }: CoreStart): TaskManagerStartContract { const savedObjectsRepository = savedObjects.createInternalRepository(['task']); + const serializer = savedObjects.createSerializer(); const taskStore = new TaskStore({ - serializer: savedObjects.createSerializer(), + serializer, savedObjectsRepository, esClient: elasticsearch.createClient('taskManager').asInternalUser, index: this.config!.index, - maxAttempts: this.config!.max_attempts, definitions: this.definitions, taskManagerId: `kibana:${this.taskManagerId!}`, }); @@ -151,6 +150,7 @@ export class TaskManagerPlugin taskStore, middleware: this.middleware, taskPollingLifecycle: this.taskPollingLifecycle, + definitions: this.definitions, }); return { diff --git a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts index d4617d6549d60d..f3af6f50336eae 100644 --- a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts +++ b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts @@ -64,6 +64,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 8, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) @@ -79,6 +80,63 @@ describe('delayOnClaimConflicts', () => { }) ); + test( + 'emits delay only once, no mater how many subscribers there are', + fakeSchedulers(async () => { + const taskLifecycleEvents$ = new Subject(); + + const delays$ = delayOnClaimConflicts(of(10), of(100), taskLifecycleEvents$, 80, 2); + + const firstSubscriber$ = delays$.pipe(take(2), bufferCount(2)).toPromise(); + const secondSubscriber$ = delays$.pipe(take(2), bufferCount(2)).toPromise(); + + taskLifecycleEvents$.next( + asTaskPollingCycleEvent( + asOk({ + result: FillPoolResult.PoolFilled, + stats: { + tasksUpdated: 0, + tasksConflicted: 8, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: [], + }) + ) + ); + + const thirdSubscriber$ = delays$.pipe(take(2), bufferCount(2)).toPromise(); + + taskLifecycleEvents$.next( + asTaskPollingCycleEvent( + asOk({ + result: FillPoolResult.PoolFilled, + stats: { + tasksUpdated: 0, + tasksConflicted: 10, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: [], + }) + ) + ); + + // should get the initial value of 0 delay + const [initialDelay, firstRandom] = await firstSubscriber$; + // should get the 0 delay (as a replay), which was the last value plus the first random value + const [initialDelayInSecondSub, firstRandomInSecondSub] = await secondSubscriber$; + // should get the first random value (as a replay) and the next random value + const [firstRandomInThirdSub, secondRandomInThirdSub] = await thirdSubscriber$; + + expect(initialDelay).toEqual(0); + expect(initialDelayInSecondSub).toEqual(0); + expect(firstRandom).toEqual(firstRandomInSecondSub); + expect(firstRandomInSecondSub).toEqual(firstRandomInThirdSub); + expect(secondRandomInThirdSub).toBeGreaterThanOrEqual(0); + }) + ); + test( 'doesnt emit a new delay when conflicts have reduced', fakeSchedulers(async () => { @@ -107,6 +165,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 8, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) @@ -127,6 +186,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 7, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) @@ -145,6 +205,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 9, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) diff --git a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts index 73e7052b65a69e..6d7cb77625b580 100644 --- a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts +++ b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts @@ -11,7 +11,7 @@ import stats from 'stats-lite'; import { isNumber, random } from 'lodash'; -import { merge, of, Observable, combineLatest } from 'rxjs'; +import { merge, of, Observable, combineLatest, ReplaySubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { Option, none, some, isSome, Some } from 'fp-ts/lib/Option'; import { isOk } from '../lib/result_type'; @@ -32,7 +32,9 @@ export function delayOnClaimConflicts( runningAverageWindowSize: number ): Observable { const claimConflictQueue = createRunningAveragedStat(runningAverageWindowSize); - return merge( + // return a subject to allow multicast and replay the last value to new subscribers + const multiCastDelays$ = new ReplaySubject(1); + merge( of(0), combineLatest([ maxWorkersConfiguration$, @@ -70,5 +72,9 @@ export function delayOnClaimConflicts( return random(pollInterval * 0.25, pollInterval * 0.75, false); }) ) - ); + ).subscribe((delay) => { + multiCastDelays$.next(delay); + }); + + return multiCastDelays$; } diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts index 9f794450702379..63d7f6de81801f 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts @@ -7,17 +7,30 @@ import _ from 'lodash'; import sinon from 'sinon'; -import { of, Subject } from 'rxjs'; +import { Observable, of, Subject } from 'rxjs'; import { TaskPollingLifecycle, claimAvailableTasks } from './polling_lifecycle'; import { createInitialMiddleware } from './lib/middleware'; import { TaskTypeDictionary } from './task_type_dictionary'; import { taskStoreMock } from './task_store.mock'; import { mockLogger } from './test_utils'; +import { taskClaimingMock } from './queries/task_claiming.mock'; +import { TaskClaiming, ClaimOwnershipResult } from './queries/task_claiming'; +import type { TaskClaiming as TaskClaimingClass } from './queries/task_claiming'; +import { asOk, Err, isErr, isOk, Result } from './lib/result_type'; +import { FillPoolResult } from './lib/fill_pool'; + +let mockTaskClaiming = taskClaimingMock.create({}); +jest.mock('./queries/task_claiming', () => { + return { + TaskClaiming: jest.fn().mockImplementation(() => { + return mockTaskClaiming; + }), + }; +}); describe('TaskPollingLifecycle', () => { let clock: sinon.SinonFakeTimers; - const taskManagerLogger = mockLogger(); const mockTaskStore = taskStoreMock.create({}); const taskManagerOpts = { @@ -50,8 +63,9 @@ describe('TaskPollingLifecycle', () => { }; beforeEach(() => { + mockTaskClaiming = taskClaimingMock.create({}); + (TaskClaiming as jest.Mock).mockClear(); clock = sinon.useFakeTimers(); - taskManagerOpts.definitions = new TaskTypeDictionary(taskManagerLogger); }); afterEach(() => clock.restore()); @@ -60,17 +74,58 @@ describe('TaskPollingLifecycle', () => { test('begins polling once the ES and SavedObjects services are available', () => { const elasticsearchAndSOAvailability$ = new Subject(); new TaskPollingLifecycle({ - elasticsearchAndSOAvailability$, ...taskManagerOpts, + elasticsearchAndSOAvailability$, }); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).not.toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).not.toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); + }); + + test('provides TaskClaiming with the capacity available', () => { + const elasticsearchAndSOAvailability$ = new Subject(); + const maxWorkers$ = new Subject(); + taskManagerOpts.definitions.registerTaskDefinitions({ + report: { + title: 'report', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + quickReport: { + title: 'quickReport', + maxConcurrency: 5, + createTaskRunner: jest.fn(), + }, + }); + + new TaskPollingLifecycle({ + ...taskManagerOpts, + elasticsearchAndSOAvailability$, + maxWorkersConfiguration$: maxWorkers$, + }); + + const taskClaimingGetCapacity = (TaskClaiming as jest.Mock).mock + .calls[0][0].getCapacity; + + maxWorkers$.next(20); + expect(taskClaimingGetCapacity()).toEqual(20); + expect(taskClaimingGetCapacity('report')).toEqual(1); + expect(taskClaimingGetCapacity('quickReport')).toEqual(5); + + maxWorkers$.next(30); + expect(taskClaimingGetCapacity()).toEqual(30); + expect(taskClaimingGetCapacity('report')).toEqual(1); + expect(taskClaimingGetCapacity('quickReport')).toEqual(5); + + maxWorkers$.next(2); + expect(taskClaimingGetCapacity()).toEqual(2); + expect(taskClaimingGetCapacity('report')).toEqual(1); + expect(taskClaimingGetCapacity('quickReport')).toEqual(2); }); }); @@ -85,13 +140,13 @@ describe('TaskPollingLifecycle', () => { elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(false); - mockTaskStore.claimAvailableTasks.mockClear(); + mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockClear(); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).not.toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).not.toHaveBeenCalled(); }); test('restarts polling once the ES and SavedObjects services become available again', () => { @@ -104,68 +159,64 @@ describe('TaskPollingLifecycle', () => { elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(false); - mockTaskStore.claimAvailableTasks.mockClear(); + mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockClear(); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).not.toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).not.toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); }); }); describe('claimAvailableTasks', () => { - test('should claim Available Tasks when there are available workers', () => { - const logger = mockLogger(); - const claim = jest.fn(() => - Promise.resolve({ - docs: [], - stats: { tasksUpdated: 0, tasksConflicted: 0, tasksClaimed: 0 }, - }) - ); - - const availableWorkers = 1; - - claimAvailableTasks([], claim, availableWorkers, logger); - - expect(claim).toHaveBeenCalledTimes(1); - }); - - test('should not claim Available Tasks when there are no available workers', () => { + test('should claim Available Tasks when there are available workers', async () => { const logger = mockLogger(); - const claim = jest.fn(() => - Promise.resolve({ - docs: [], - stats: { tasksUpdated: 0, tasksConflicted: 0, tasksClaimed: 0 }, - }) + const taskClaiming = taskClaimingMock.create({}); + taskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockImplementation(() => + of( + asOk({ + docs: [], + stats: { tasksUpdated: 0, tasksConflicted: 0, tasksClaimed: 0, tasksRejected: 0 }, + }) + ) ); - const availableWorkers = 0; + expect( + isOk(await getFirstAsPromise(claimAvailableTasks([], taskClaiming, logger))) + ).toBeTruthy(); - claimAvailableTasks([], claim, availableWorkers, logger); - - expect(claim).not.toHaveBeenCalled(); + expect(taskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalledTimes(1); }); /** * This handles the case in which Elasticsearch has had inline script disabled. * This is achieved by setting the `script.allowed_types` flag on Elasticsearch to `none` */ - test('handles failure due to inline scripts being disabled', () => { + test('handles failure due to inline scripts being disabled', async () => { const logger = mockLogger(); - const claim = jest.fn(() => { - throw Object.assign(new Error(), { - response: - '{"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}],"type":"search_phase_execution_exception","reason":"all shards failed","phase":"query","grouped":true,"failed_shards":[{"shard":0,"index":".kibana_task_manager_1","node":"24A4QbjHSK6prvtopAKLKw","reason":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}],"caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts","caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}},"status":400}', - }); - }); + const taskClaiming = taskClaimingMock.create({}); + taskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockImplementation( + () => + new Observable>((observer) => { + observer.error( + Object.assign(new Error(), { + response: + '{"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}],"type":"search_phase_execution_exception","reason":"all shards failed","phase":"query","grouped":true,"failed_shards":[{"shard":0,"index":".kibana_task_manager_1","node":"24A4QbjHSK6prvtopAKLKw","reason":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}],"caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts","caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}},"status":400}', + }) + ); + }) + ); + + const err = await getFirstAsPromise(claimAvailableTasks([], taskClaiming, logger)); - claimAvailableTasks([], claim, 10, logger); + expect(isErr(err)).toBeTruthy(); + expect((err as Err).error).toEqual(FillPoolResult.Failed); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).toHaveBeenCalledWith( @@ -174,3 +225,9 @@ describe('TaskPollingLifecycle', () => { }); }); }); + +function getFirstAsPromise(obs$: Observable): Promise { + return new Promise((resolve, reject) => { + obs$.subscribe(resolve, reject); + }); +} diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.ts index db8eeaaf78dee5..260f5ccc70f53c 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.ts @@ -6,15 +6,12 @@ */ import { Subject, Observable, Subscription } from 'rxjs'; - -import { performance } from 'perf_hooks'; - import { pipe } from 'fp-ts/lib/pipeable'; import { Option, some, map as mapOptional } from 'fp-ts/lib/Option'; import { tap } from 'rxjs/operators'; import { Logger } from '../../../../src/core/server'; -import { Result, asErr, mapErr, asOk, map } from './lib/result_type'; +import { Result, asErr, mapErr, asOk, map, mapOk } from './lib/result_type'; import { ManagedConfiguration } from './lib/create_managed_configuration'; import { TaskManagerConfig } from './config'; @@ -41,11 +38,12 @@ import { } from './polling'; import { TaskPool } from './task_pool'; import { TaskManagerRunner, TaskRunner } from './task_running'; -import { TaskStore, OwnershipClaimingOpts, ClaimOwnershipResult } from './task_store'; +import { TaskStore } from './task_store'; import { identifyEsError } from './lib/identify_es_error'; import { BufferedTaskStore } from './buffered_task_store'; import { TaskTypeDictionary } from './task_type_dictionary'; import { delayOnClaimConflicts } from './polling'; +import { TaskClaiming, ClaimOwnershipResult } from './queries/task_claiming'; export type TaskPollingLifecycleOpts = { logger: Logger; @@ -71,6 +69,7 @@ export class TaskPollingLifecycle { private definitions: TaskTypeDictionary; private store: TaskStore; + private taskClaiming: TaskClaiming; private bufferedStore: BufferedTaskStore; private logger: Logger; @@ -106,8 +105,6 @@ export class TaskPollingLifecycle { this.store = taskStore; const emitEvent = (event: TaskLifecycleEvent) => this.events$.next(event); - // pipe store events into the lifecycle event stream - this.store.events.subscribe(emitEvent); this.bufferedStore = new BufferedTaskStore(this.store, { bufferMaxOperations: config.max_workers, @@ -120,6 +117,26 @@ export class TaskPollingLifecycle { }); this.pool.load.subscribe(emitEvent); + this.taskClaiming = new TaskClaiming({ + taskStore, + maxAttempts: config.max_attempts, + definitions, + logger: this.logger, + getCapacity: (taskType?: string) => + taskType && this.definitions.get(taskType)?.maxConcurrency + ? Math.max( + Math.min( + this.pool.availableWorkers, + this.definitions.get(taskType)!.maxConcurrency! - + this.pool.getOccupiedWorkersByType(taskType) + ), + 0 + ) + : this.pool.availableWorkers, + }); + // pipe taskClaiming events into the lifecycle event stream + this.taskClaiming.events.subscribe(emitEvent); + const { max_poll_inactivity_cycles: maxPollInactivityCycles, poll_interval: pollInterval, @@ -199,6 +216,7 @@ export class TaskPollingLifecycle { beforeRun: this.middleware.beforeRun, beforeMarkRunning: this.middleware.beforeMarkRunning, onTaskEvent: this.emitEvent, + defaultMaxAttempts: this.taskClaiming.maxAttempts, }); }; @@ -212,9 +230,18 @@ export class TaskPollingLifecycle { () => claimAvailableTasks( tasksToClaim.splice(0, this.pool.availableWorkers), - this.store.claimAvailableTasks, - this.pool.availableWorkers, + this.taskClaiming, this.logger + ).pipe( + tap( + mapOk(({ timing }: ClaimOwnershipResult) => { + if (timing) { + this.emitEvent( + asTaskManagerStatEvent('claimDuration', asOk(timing.stop - timing.start)) + ); + } + }) + ) ), // wrap each task in a Task Runner this.createTaskRunnerForTask, @@ -252,59 +279,40 @@ export class TaskPollingLifecycle { } } -export async function claimAvailableTasks( +export function claimAvailableTasks( claimTasksById: string[], - claim: (opts: OwnershipClaimingOpts) => Promise, - availableWorkers: number, + taskClaiming: TaskClaiming, logger: Logger -): Promise> { - if (availableWorkers > 0) { - performance.mark('claimAvailableTasks_start'); - - try { - const claimResult = await claim({ - size: availableWorkers, +): Observable> { + return new Observable((observer) => { + taskClaiming + .claimAvailableTasksIfCapacityIsAvailable({ claimOwnershipUntil: intervalFromNow('30s')!, claimTasksById, - }); - const { - docs, - stats: { tasksClaimed }, - } = claimResult; - - if (tasksClaimed === 0) { - performance.mark('claimAvailableTasks.noTasks'); - } - performance.mark('claimAvailableTasks_stop'); - performance.measure( - 'claimAvailableTasks', - 'claimAvailableTasks_start', - 'claimAvailableTasks_stop' + }) + .subscribe( + (claimResult) => { + observer.next(claimResult); + }, + (ex) => { + // if the `taskClaiming` stream errors out we want to catch it and see if + // we can identify the reason + // if we can - we emit an FillPoolResult error rather than erroring out the wrapping Observable + // returned by `claimAvailableTasks` + if (identifyEsError(ex).includes('cannot execute [inline] scripts')) { + logger.warn( + `Task Manager cannot operate when inline scripts are disabled in Elasticsearch` + ); + observer.next(asErr(FillPoolResult.Failed)); + observer.complete(); + } else { + // as we could't identify the reason - we'll error out the wrapping Observable too + observer.error(ex); + } + }, + () => { + observer.complete(); + } ); - - if (docs.length !== tasksClaimed) { - logger.warn( - `[Task Ownership error]: ${tasksClaimed} tasks were claimed by Kibana, but ${ - docs.length - } task(s) were fetched (${docs.map((doc) => doc.id).join(', ')})` - ); - } - return asOk(claimResult); - } catch (ex) { - if (identifyEsError(ex).includes('cannot execute [inline] scripts')) { - logger.warn( - `Task Manager cannot operate when inline scripts are disabled in Elasticsearch` - ); - return asErr(FillPoolResult.Failed); - } else { - throw ex; - } - } - } else { - performance.mark('claimAvailableTasks.noAvailableWorkers'); - logger.debug( - `[Task Ownership]: Task Manager has skipped Claiming Ownership of available tasks at it has ran out Available Workers.` - ); - return asErr(FillPoolResult.NoAvailableWorkers); - } + }); } diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts index 75b9b2cdfa9779..57a4ab320367d4 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts @@ -52,6 +52,7 @@ describe('mark_available_tasks_as_claimed', () => { fieldUpdates, claimTasksById || [], definitions.getAllTypes(), + [], Array.from(definitions).reduce((accumulator, [type, { maxAttempts }]) => { return { ...accumulator, [type]: maxAttempts || defaultMaxAttempts }; }, {}) @@ -116,18 +117,23 @@ if (doc['task.runAt'].size()!=0) { seq_no_primary_term: true, script: { source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + if (params.claimableTaskTypes.contains(ctx._source.task.taskType)) { + if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')} + } else { + ctx._source.task.status = "failed"; + } + } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) .join(' ')} + } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + ctx._source.task.status = "unrecognized"; } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, + ctx.op = "noop"; + }`, lang: 'painless', params: { fieldUpdates: { @@ -135,7 +141,8 @@ if (doc['task.runAt'].size()!=0) { retryAt: claimOwnershipUntil, }, claimTasksById: [], - registeredTaskTypes: ['sampleTask', 'otherTask'], + claimableTaskTypes: ['sampleTask', 'otherTask'], + skippedTaskTypes: [], taskMaxAttempts: { sampleTask: 5, otherTask: 1, @@ -144,4 +151,76 @@ if (doc['task.runAt'].size()!=0) { }, }); }); + + describe(`script`, () => { + test('it supports claiming specific tasks by id', async () => { + const taskManagerId = '3478fg6-82374f6-83467gf5-384g6f'; + const claimOwnershipUntil = '2019-02-12T21:01:22.479Z'; + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: claimOwnershipUntil, + }; + + const claimTasksById = [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ]; + + expect( + updateFieldsAndMarkAsFailed(fieldUpdates, claimTasksById, ['foo', 'bar'], [], { + foo: 5, + bar: 2, + }) + ).toMatchObject({ + source: ` + if (params.claimableTaskTypes.contains(ctx._source.task.taskType)) { + if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')} + } else { + ctx._source.task.status = "failed"; + } + } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { + ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')} + } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + ctx._source.task.status = "unrecognized"; + } else { + ctx.op = "noop"; + }`, + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: [], + taskMaxAttempts: { + foo: 5, + bar: 2, + }, + }, + }); + }); + + test('it marks the update as a noop if the type is skipped', async () => { + const taskManagerId = '3478fg6-82374f6-83467gf5-384g6f'; + const claimOwnershipUntil = '2019-02-12T21:01:22.479Z'; + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: claimOwnershipUntil, + }; + + expect( + updateFieldsAndMarkAsFailed(fieldUpdates, [], ['foo', 'bar'], [], { + foo: 5, + bar: 2, + }).source + ).toMatch(/ctx.op = "noop"/); + }); + }); }); diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts index 067de5a92adb7b..8598980a4e2363 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts @@ -14,6 +14,8 @@ import { mustBeAllOf, MustCondition, BoolClauseWithAnyCondition, + ShouldCondition, + FilterCondition, } from './query_clauses'; export const TaskWithSchedule: ExistsFilter = { @@ -39,14 +41,26 @@ export function taskWithLessThanMaxAttempts( }; } -export function tasksClaimedByOwner(taskManagerId: string) { +export function tasksOfType(taskTypes: string[]): ShouldCondition { + return { + bool: { + should: [...taskTypes].map((type) => ({ term: { 'task.taskType': type } })), + }, + }; +} + +export function tasksClaimedByOwner( + taskManagerId: string, + ...taskFilters: Array | ShouldCondition> +) { return mustBeAllOf( { term: { 'task.ownerId': taskManagerId, }, }, - { term: { 'task.status': 'claiming' } } + { term: { 'task.status': 'claiming' } }, + ...taskFilters ); } @@ -107,27 +121,35 @@ export const updateFieldsAndMarkAsFailed = ( [field: string]: string | number | Date; }, claimTasksById: string[], - registeredTaskTypes: string[], + claimableTaskTypes: string[], + skippedTaskTypes: string[], taskMaxAttempts: { [field: string]: number } -): ScriptClause => ({ - source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { - ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) - .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) - .join(' ')} +): ScriptClause => { + const markAsClaimingScript = `ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')}`; + return { + source: ` + if (params.claimableTaskTypes.contains(ctx._source.task.taskType)) { + if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + ${markAsClaimingScript} + } else { + ctx._source.task.status = "failed"; + } + } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { + ${markAsClaimingScript} + } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + ctx._source.task.status = "unrecognized"; } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, - lang: 'painless', - params: { - fieldUpdates, - claimTasksById, - registeredTaskTypes, - taskMaxAttempts, - }, -}); + ctx.op = "noop"; + }`, + lang: 'painless', + params: { + fieldUpdates, + claimTasksById, + claimableTaskTypes, + skippedTaskTypes, + taskMaxAttempts, + }, + }; +}; diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.mock.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.mock.ts new file mode 100644 index 00000000000000..38f02780c485e9 --- /dev/null +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.mock.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 { Observable, Subject } from 'rxjs'; +import { TaskClaim } from '../task_events'; + +import { TaskClaiming } from './task_claiming'; + +interface TaskClaimingOptions { + maxAttempts?: number; + taskManagerId?: string; + events?: Observable; +} +export const taskClaimingMock = { + create({ + maxAttempts = 0, + taskManagerId = '', + events = new Subject(), + }: TaskClaimingOptions) { + const mocked = ({ + claimAvailableTasks: jest.fn(), + claimAvailableTasksIfCapacityIsAvailable: jest.fn(), + maxAttempts, + taskManagerId, + events, + } as unknown) as jest.Mocked; + return mocked; + }, +}; diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts new file mode 100644 index 00000000000000..bd1171d7fd2f82 --- /dev/null +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts @@ -0,0 +1,1516 @@ +/* + * Copyright 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 _ from 'lodash'; +import uuid from 'uuid'; +import { filter, take, toArray } from 'rxjs/operators'; +import { some, none } from 'fp-ts/lib/Option'; + +import { TaskStatus, ConcreteTaskInstance } from '../task'; +import { SearchOpts, StoreOpts, UpdateByQueryOpts, UpdateByQuerySearchOpts } from '../task_store'; +import { asTaskClaimEvent, ClaimTaskErr, TaskClaimErrorType, TaskEvent } from '../task_events'; +import { asOk, asErr } from '../lib/result_type'; +import { TaskTypeDictionary } from '../task_type_dictionary'; +import { BoolClauseWithAnyCondition, TermFilter } from '../queries/query_clauses'; +import { mockLogger } from '../test_utils'; +import { TaskClaiming, OwnershipClaimingOpts, TaskClaimingOpts } from './task_claiming'; +import { Observable } from 'rxjs'; +import { taskStoreMock } from '../task_store.mock'; + +const taskManagerLogger = mockLogger(); + +beforeEach(() => jest.resetAllMocks()); + +const mockedDate = new Date('2019-02-12T21:01:22.479Z'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).Date = class Date { + constructor() { + return mockedDate; + } + static now() { + return mockedDate.getTime(); + } +}; + +const taskDefinitions = new TaskTypeDictionary(taskManagerLogger); +taskDefinitions.registerTaskDefinitions({ + report: { + title: 'report', + createTaskRunner: jest.fn(), + }, + dernstraight: { + title: 'dernstraight', + createTaskRunner: jest.fn(), + }, + yawn: { + title: 'yawn', + createTaskRunner: jest.fn(), + }, +}); + +describe('TaskClaiming', () => { + test(`should log when a certain task type is skipped due to having a zero concurency configuration`, () => { + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + anotherUnlimited: { + title: 'anotherUnlimited', + createTaskRunner: jest.fn(), + }, + limitedToZero: { + title: 'limitedToZero', + maxConcurrency: 0, + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToZero: { + title: 'anotherLimitedToZero', + maxConcurrency: 0, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + + new TaskClaiming({ + logger: taskManagerLogger, + definitions, + taskStore: taskStoreMock.create({ taskManagerId: '' }), + maxAttempts: 2, + getCapacity: () => 10, + }); + + expect(taskManagerLogger.info).toHaveBeenCalledTimes(1); + expect(taskManagerLogger.info.mock.calls[0][0]).toMatchInlineSnapshot( + `"Task Manager will never claim tasks of the following types as their \\"maxConcurrency\\" is set to 0: limitedToZero, anotherLimitedToZero"` + ); + }); + + describe('claimAvailableTasks', () => { + function initialiseTestClaiming({ + storeOpts = {}, + taskClaimingOpts = {}, + hits = [generateFakeTasks(1)], + versionConflicts = 2, + }: { + storeOpts: Partial; + taskClaimingOpts: Partial; + hits?: ConcreteTaskInstance[][]; + versionConflicts?: number; + }) { + const definitions = storeOpts.definitions ?? taskDefinitions; + const store = taskStoreMock.create({ taskManagerId: storeOpts.taskManagerId }); + store.convertToSavedObjectIds.mockImplementation((ids) => ids.map((id) => `task:${id}`)); + + if (hits.length === 1) { + store.fetch.mockResolvedValue({ docs: hits[0] }); + store.updateByQuery.mockResolvedValue({ + updated: hits[0].length, + version_conflicts: versionConflicts, + total: hits[0].length, + }); + } else { + for (const docs of hits) { + store.fetch.mockResolvedValueOnce({ docs }); + store.updateByQuery.mockResolvedValueOnce({ + updated: docs.length, + version_conflicts: versionConflicts, + total: docs.length, + }); + } + } + + const taskClaiming = new TaskClaiming({ + logger: taskManagerLogger, + definitions, + taskStore: store, + maxAttempts: taskClaimingOpts.maxAttempts ?? 2, + getCapacity: taskClaimingOpts.getCapacity ?? (() => 10), + ...taskClaimingOpts, + }); + + return { taskClaiming, store }; + } + + async function testClaimAvailableTasks({ + storeOpts = {}, + taskClaimingOpts = {}, + claimingOpts, + hits = [generateFakeTasks(1)], + versionConflicts = 2, + }: { + storeOpts: Partial; + taskClaimingOpts: Partial; + claimingOpts: Omit; + hits?: ConcreteTaskInstance[][]; + versionConflicts?: number; + }) { + const getCapacity = taskClaimingOpts.getCapacity ?? (() => 10); + const { taskClaiming, store } = initialiseTestClaiming({ + storeOpts, + taskClaimingOpts, + hits, + versionConflicts, + }); + + const results = await getAllAsPromise(taskClaiming.claimAvailableTasks(claimingOpts)); + + expect(store.updateByQuery.mock.calls[0][1]).toMatchObject({ + max_docs: getCapacity(), + }); + expect(store.fetch.mock.calls[0][0]).toMatchObject({ size: getCapacity() }); + return results.map((result, index) => ({ + result, + args: { + search: store.fetch.mock.calls[index][0] as SearchOpts & { + query: BoolClauseWithAnyCondition; + }, + updateByQuery: store.updateByQuery.mock.calls[index] as [ + UpdateByQuerySearchOpts, + UpdateByQueryOpts + ], + }, + })); + } + + test('it filters claimed tasks down by supported types, maxAttempts, status, and runAt', async () => { + const maxAttempts = _.random(2, 43); + const customMaxAttempts = _.random(44, 100); + + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + }); + + const [ + { + args: { + updateByQuery: [{ query, sort }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + definitions, + }, + taskClaimingOpts: { + maxAttempts, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + }, + }); + expect(query).toMatchObject({ + bool: { + must: [ + { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'task.status': 'idle' } }, + { range: { 'task.runAt': { lte: 'now' } } }, + ], + }, + }, + { + bool: { + must: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + }, + }, + { range: { 'task.retryAt': { lte: 'now' } } }, + ], + }, + }, + ], + }, + }, + ], + filter: [ + { + bool: { + must_not: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + must: { range: { 'task.retryAt': { gt: 'now' } } }, + }, + }, + ], + }, + }, + ], + }, + }); + expect(sort).toMatchObject([ + { + _script: { + type: 'number', + order: 'asc', + script: { + lang: 'painless', + source: ` +if (doc['task.retryAt'].size()!=0) { + return doc['task.retryAt'].value.toInstant().toEpochMilli(); +} +if (doc['task.runAt'].size()!=0) { + return doc['task.runAt'].value.toInstant().toEpochMilli(); +} + `, + }, + }, + }, + ]); + }); + + test('it supports claiming specific tasks by id', async () => { + const maxAttempts = _.random(2, 43); + const customMaxAttempts = _.random(44, 100); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: new Date(Date.now()), + }; + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + }); + const [ + { + args: { + updateByQuery: [{ query, script, sort }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + claimTasksById: [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + }, + }); + + expect(query).toMatchObject({ + bool: { + must: [ + { + pinned: { + ids: [ + 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + organic: { + bool: { + must: [ + { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'task.status': 'idle' } }, + { range: { 'task.runAt': { lte: 'now' } } }, + ], + }, + }, + { + bool: { + must: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + }, + }, + { range: { 'task.retryAt': { lte: 'now' } } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + ], + filter: [ + { + bool: { + must_not: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + must: { range: { 'task.retryAt': { gt: 'now' } } }, + }, + }, + ], + }, + }, + ], + }, + }); + + expect(script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [ + 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: [], + taskMaxAttempts: { + bar: customMaxAttempts, + foo: maxAttempts, + }, + }, + }); + + expect(sort).toMatchObject([ + '_score', + { + _script: { + type: 'number', + order: 'asc', + script: { + lang: 'painless', + source: ` +if (doc['task.retryAt'].size()!=0) { + return doc['task.retryAt'].value.toInstant().toEpochMilli(); +} +if (doc['task.runAt'].size()!=0) { + return doc['task.runAt'].value.toInstant().toEpochMilli(); +} + `, + }, + }, + }, + ]); + }); + + test('it should claim in batches partitioned by maxConcurrency', async () => { + const maxAttempts = _.random(2, 43); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: new Date(Date.now()), + }; + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + limitedToZero: { + title: 'limitedToZero', + maxConcurrency: 0, + createTaskRunner: jest.fn(), + }, + anotherUnlimited: { + title: 'anotherUnlimited', + createTaskRunner: jest.fn(), + }, + finalUnlimited: { + title: 'finalUnlimited', + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToOne: { + title: 'anotherLimitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + const results = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + getCapacity: (type) => { + switch (type) { + case 'limitedToOne': + case 'anotherLimitedToOne': + return 1; + case 'limitedToTwo': + return 2; + default: + return 10; + } + }, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + claimTasksById: [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + }, + }); + + expect(results.length).toEqual(4); + + expect(results[0].args.updateByQuery[1].max_docs).toEqual(10); + expect(results[0].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [ + 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + claimableTaskTypes: ['unlimited', 'anotherUnlimited', 'finalUnlimited'], + skippedTaskTypes: [ + 'limitedToZero', + 'limitedToOne', + 'anotherLimitedToOne', + 'limitedToTwo', + ], + taskMaxAttempts: { + unlimited: maxAttempts, + }, + }, + }); + + expect(results[1].args.updateByQuery[1].max_docs).toEqual(1); + expect(results[1].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['limitedToOne'], + skippedTaskTypes: [ + 'unlimited', + 'limitedToZero', + 'anotherUnlimited', + 'finalUnlimited', + 'anotherLimitedToOne', + 'limitedToTwo', + ], + taskMaxAttempts: { + limitedToOne: maxAttempts, + }, + }, + }); + + expect(results[2].args.updateByQuery[1].max_docs).toEqual(1); + expect(results[2].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['anotherLimitedToOne'], + skippedTaskTypes: [ + 'unlimited', + 'limitedToZero', + 'anotherUnlimited', + 'finalUnlimited', + 'limitedToOne', + 'limitedToTwo', + ], + taskMaxAttempts: { + anotherLimitedToOne: maxAttempts, + }, + }, + }); + + expect(results[3].args.updateByQuery[1].max_docs).toEqual(2); + expect(results[3].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['limitedToTwo'], + skippedTaskTypes: [ + 'unlimited', + 'limitedToZero', + 'anotherUnlimited', + 'finalUnlimited', + 'limitedToOne', + 'anotherLimitedToOne', + ], + taskMaxAttempts: { + limitedToTwo: maxAttempts, + }, + }, + }); + }); + + test('it should reduce the available capacity from batch to batch', async () => { + const maxAttempts = _.random(2, 43); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + limitedToFive: { + title: 'limitedToFive', + maxConcurrency: 5, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + const results = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + getCapacity: (type) => { + switch (type) { + case 'limitedToTwo': + return 2; + case 'limitedToFive': + return 5; + default: + return 10; + } + }, + }, + hits: [ + [ + // 7 returned by unlimited query + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + ], + // 2 returned by limitedToFive query + [ + mockInstance({ + taskType: 'limitedToFive', + }), + mockInstance({ + taskType: 'limitedToFive', + }), + ], + // 1 reterned by limitedToTwo query + [ + mockInstance({ + taskType: 'limitedToTwo', + }), + ], + ], + claimingOpts: { + claimOwnershipUntil: new Date(), + claimTasksById: [], + }, + }); + + expect(results.length).toEqual(3); + + expect(results[0].args.updateByQuery[1].max_docs).toEqual(10); + + // only capacity for 3, even though 5 are allowed + expect(results[1].args.updateByQuery[1].max_docs).toEqual(3); + + // only capacity for 1, even though 2 are allowed + expect(results[2].args.updateByQuery[1].max_docs).toEqual(1); + }); + + test('it shuffles the types claimed in batches to ensure no type starves another', async () => { + const maxAttempts = _.random(2, 43); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + anotherUnlimited: { + title: 'anotherUnlimited', + createTaskRunner: jest.fn(), + }, + finalUnlimited: { + title: 'finalUnlimited', + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToOne: { + title: 'anotherLimitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + + const { taskClaiming, store } = initialiseTestClaiming({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + getCapacity: (type) => { + switch (type) { + case 'limitedToOne': + case 'anotherLimitedToOne': + return 1; + case 'limitedToTwo': + return 2; + default: + return 10; + } + }, + }, + }); + + async function getUpdateByQueryScriptParams() { + return ( + await getAllAsPromise( + taskClaiming.claimAvailableTasks({ + claimOwnershipUntil: new Date(), + }) + ) + ).map( + (result, index) => + (store.updateByQuery.mock.calls[index][0] as { + query: BoolClauseWithAnyCondition; + size: number; + sort: string | string[]; + script: { + params: { + claimableTaskTypes: string[]; + }; + }; + }).script.params.claimableTaskTypes + ); + } + + const firstCycle = await getUpdateByQueryScriptParams(); + store.updateByQuery.mockClear(); + const secondCycle = await getUpdateByQueryScriptParams(); + + expect(firstCycle.length).toEqual(4); + expect(secondCycle.length).toEqual(4); + expect(firstCycle).not.toMatchObject(secondCycle); + }); + + test('it claims tasks by setting their ownerId, status and retryAt', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: claimOwnershipUntil, + }; + const [ + { + args: { + updateByQuery: [{ script }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil, + }, + }); + expect(script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimableTaskTypes: ['report', 'dernstraight', 'yawn'], + skippedTaskTypes: [], + taskMaxAttempts: { + dernstraight: 2, + report: 2, + yawn: 2, + }, + }, + }); + }); + + test('it filters out running tasks', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + mockInstance({ + id: 'aaa', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }), + ]; + const [ + { + result: { docs }, + args: { + search: { query }, + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil, + }, + hits: [tasks], + }); + + expect(query).toMatchObject({ + bool: { + must: [ + { + term: { + 'task.ownerId': taskManagerId, + }, + }, + { term: { 'task.status': 'claiming' } }, + { + bool: { + should: [ + { + term: { + 'task.taskType': 'report', + }, + }, + { + term: { + 'task.taskType': 'dernstraight', + }, + }, + { + term: { + 'task.taskType': 'yawn', + }, + }, + ], + }, + }, + ], + }, + }); + + expect(docs).toMatchObject([ + { + attempts: 0, + id: 'aaa', + schedule: undefined, + params: { hello: 'world' }, + runAt, + scope: ['reporting'], + state: { baby: 'Henhen' }, + status: 'claiming', + taskType: 'foo', + user: 'jimbo', + ownerId: taskManagerId, + }, + ]); + }); + + test('it returns task objects', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + mockInstance({ + id: 'aaa', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }), + mockInstance({ + id: 'bbb', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Claiming, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + }), + ]; + const [ + { + result: { docs }, + args: { + search: { query }, + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil, + }, + hits: [tasks], + }); + + expect(query).toMatchObject({ + bool: { + must: [ + { + term: { + 'task.ownerId': taskManagerId, + }, + }, + { term: { 'task.status': 'claiming' } }, + { + bool: { + should: [ + { + term: { + 'task.taskType': 'report', + }, + }, + { + term: { + 'task.taskType': 'dernstraight', + }, + }, + { + term: { + 'task.taskType': 'yawn', + }, + }, + ], + }, + }, + ], + }, + }); + + expect(docs).toMatchObject([ + { + attempts: 0, + id: 'aaa', + schedule: undefined, + params: { hello: 'world' }, + runAt, + scope: ['reporting'], + state: { baby: 'Henhen' }, + status: 'claiming', + taskType: 'foo', + user: 'jimbo', + ownerId: taskManagerId, + }, + { + attempts: 2, + id: 'bbb', + schedule: { interval: '5m' }, + params: { shazm: 1 }, + runAt, + scope: ['reporting', 'ceo'], + state: { henry: 'The 8th' }, + status: 'claiming', + taskType: 'bar', + user: 'dabo', + ownerId: taskManagerId, + }, + ]); + }); + + test('it returns version_conflicts that do not include conflicts that were proceeded against', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + mockInstance({ + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }), + mockInstance({ + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Claiming, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + }), + ]; + const maxDocs = 10; + const [ + { + result: { + stats: { tasksUpdated, tasksConflicted, tasksClaimed }, + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: { getCapacity: () => maxDocs }, + claimingOpts: { + claimOwnershipUntil, + }, + hits: [tasks], + // assume there were 20 version conflists, but thanks to `conflicts="proceed"` + // we proceeded to claim tasks + versionConflicts: 20, + }); + + expect(tasksUpdated).toEqual(2); + // ensure we only count conflicts that *may* have counted against max_docs, no more than that + expect(tasksConflicted).toEqual(10 - tasksUpdated!); + expect(tasksClaimed).toEqual(2); + }); + }); + + describe('task events', () => { + function generateTasks(taskManagerId: string) { + const runAt = new Date(); + const tasks = [ + { + id: 'claimed-by-id', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + { + id: 'claimed-by-schedule', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Claiming, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + { + id: 'already-running', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Running, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + ]; + + return { taskManagerId, runAt, tasks }; + } + + function instantiateStoreWithMockedApiResponses({ + taskManagerId = uuid.v4(), + definitions = taskDefinitions, + getCapacity = () => 10, + tasksClaimed, + }: Partial> & { + taskManagerId?: string; + tasksClaimed?: ConcreteTaskInstance[][]; + } = {}) { + const { runAt, tasks: generatedTasks } = generateTasks(taskManagerId); + const taskCycles = tasksClaimed ?? [generatedTasks]; + + const taskStore = taskStoreMock.create({ taskManagerId }); + taskStore.convertToSavedObjectIds.mockImplementation((ids) => ids.map((id) => `task:${id}`)); + for (const docs of taskCycles) { + taskStore.fetch.mockResolvedValueOnce({ docs }); + taskStore.updateByQuery.mockResolvedValueOnce({ + updated: docs.length, + version_conflicts: 0, + total: docs.length, + }); + } + + taskStore.fetch.mockResolvedValue({ docs: [] }); + taskStore.updateByQuery.mockResolvedValue({ + updated: 0, + version_conflicts: 0, + total: 0, + }); + + const taskClaiming = new TaskClaiming({ + logger: taskManagerLogger, + definitions, + taskStore, + maxAttempts: 2, + getCapacity, + }); + + return { taskManagerId, runAt, taskClaiming }; + } + + test('emits an event when a task is succesfully claimed by id', async () => { + const { taskManagerId, runAt, taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => event.id === 'claimed-by-id' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['claimed-by-id'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'claimed-by-id', + asOk({ + id: 'claimed-by-id', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: 'claiming' as TaskStatus, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }) + ) + ); + }); + + test('emits an event when a task is succesfully claimed by id by is rejected as it would exceed maxCapacity of its taskType', async () => { + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToOne: { + title: 'anotherLimitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + }); + + const taskManagerId = uuid.v4(); + const { runAt, taskClaiming } = instantiateStoreWithMockedApiResponses({ + taskManagerId, + definitions, + getCapacity: (type) => { + switch (type) { + case 'limitedToOne': + // return 0 as there's already a `limitedToOne` task running + return 0; + default: + return 10; + } + }, + tasksClaimed: [ + // find on first claim cycle + [ + { + id: 'claimed-by-id-limited-concurrency', + runAt: new Date(), + taskType: 'limitedToOne', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + ], + // second cycle + [ + { + id: 'claimed-by-schedule-unlimited', + runAt: new Date(), + taskType: 'unlimited', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + ], + ], + }); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => + event.id === 'claimed-by-id-limited-concurrency' + ), + take(1) + ) + .toPromise(); + + const [firstCycleResult, secondCycleResult] = await getAllAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['claimed-by-id-limited-concurrency'], + claimOwnershipUntil: new Date(), + }) + ); + + expect(firstCycleResult.stats.tasksClaimed).toEqual(0); + expect(firstCycleResult.stats.tasksRejected).toEqual(1); + expect(firstCycleResult.stats.tasksUpdated).toEqual(1); + + // values accumulate from cycle to cycle + expect(secondCycleResult.stats.tasksClaimed).toEqual(0); + expect(secondCycleResult.stats.tasksRejected).toEqual(1); + expect(secondCycleResult.stats.tasksUpdated).toEqual(1); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'claimed-by-id-limited-concurrency', + asErr({ + task: some({ + id: 'claimed-by-id-limited-concurrency', + runAt, + taskType: 'limitedToOne', + schedule: undefined, + attempts: 0, + status: 'claiming' as TaskStatus, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY, + }) + ) + ); + }); + + test('emits an event when a task is succesfully by scheduling', async () => { + const { taskManagerId, runAt, taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => + event.id === 'claimed-by-schedule' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['claimed-by-id'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'claimed-by-schedule', + asOk({ + id: 'claimed-by-schedule', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: 'claiming' as TaskStatus, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }) + ) + ); + }); + + test('emits an event when the store fails to claim a required task by id', async () => { + const { taskManagerId, runAt, taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => event.id === 'already-running' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['already-running'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'already-running', + asErr({ + task: some({ + id: 'already-running', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: 'running' as TaskStatus, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS, + }) + ) + ); + }); + + test('emits an event when the store fails to find a task which was required by id', async () => { + const { taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => event.id === 'unknown-task' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['unknown-task'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'unknown-task', + asErr({ + task: none, + errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED, + }) + ) + ); + }); + }); +}); + +function generateFakeTasks(count: number = 1) { + return _.times(count, (index) => mockInstance({ id: `task:id-${index}` })); +} + +function mockInstance(instance: Partial = {}) { + return Object.assign( + { + id: uuid.v4(), + taskType: 'bar', + sequenceNumber: 32, + primaryTerm: 32, + runAt: new Date(), + scheduledAt: new Date(), + startedAt: null, + retryAt: null, + attempts: 0, + params: {}, + scope: ['reporting'], + state: {}, + status: 'idle', + user: 'example', + ownerId: null, + }, + instance + ); +} + +function getFirstAsPromise(obs$: Observable): Promise { + return new Promise((resolve, reject) => { + obs$.subscribe(resolve, reject); + }); +} +function getAllAsPromise(obs$: Observable): Promise { + return new Promise((resolve, reject) => { + obs$.pipe(toArray()).subscribe(resolve, reject); + }); +} diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.ts new file mode 100644 index 00000000000000..b4e11dbf81eb10 --- /dev/null +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.ts @@ -0,0 +1,488 @@ +/* + * Copyright 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. + */ + +/* + * This module contains helpers for managing the task manager storage layer. + */ +import apm from 'elastic-apm-node'; +import { Subject, Observable, from, of } from 'rxjs'; +import { map, mergeScan } from 'rxjs/operators'; +import { difference, partition, groupBy, mapValues, countBy, pick } from 'lodash'; +import { some, none } from 'fp-ts/lib/Option'; + +import { Logger } from '../../../../../src/core/server'; + +import { asOk, asErr, Result } from '../lib/result_type'; +import { ConcreteTaskInstance, TaskStatus } from '../task'; +import { + TaskClaim, + asTaskClaimEvent, + TaskClaimErrorType, + startTaskTimer, + TaskTiming, +} from '../task_events'; + +import { + asUpdateByQuery, + shouldBeOneOf, + mustBeAllOf, + filterDownBy, + asPinnedQuery, + matchesClauses, + SortOptions, +} from './query_clauses'; + +import { + updateFieldsAndMarkAsFailed, + IdleTaskWithExpiredRunAt, + InactiveTasks, + RunningOrClaimingTaskWithExpiredRetryAt, + SortByRunAtAndRetryAt, + tasksClaimedByOwner, + tasksOfType, +} from './mark_available_tasks_as_claimed'; +import { TaskTypeDictionary } from '../task_type_dictionary'; +import { + correctVersionConflictsForContinuation, + TaskStore, + UpdateByQueryResult, +} from '../task_store'; +import { FillPoolResult } from '../lib/fill_pool'; + +export interface TaskClaimingOpts { + logger: Logger; + definitions: TaskTypeDictionary; + taskStore: TaskStore; + maxAttempts: number; + getCapacity: (taskType?: string) => number; +} + +export interface OwnershipClaimingOpts { + claimOwnershipUntil: Date; + claimTasksById?: string[]; + size: number; + taskTypes: Set; +} +export type IncrementalOwnershipClaimingOpts = OwnershipClaimingOpts & { + precedingQueryResult: UpdateByQueryResult; +}; +export type IncrementalOwnershipClaimingReduction = ( + opts: IncrementalOwnershipClaimingOpts +) => Promise; + +export interface FetchResult { + docs: ConcreteTaskInstance[]; +} + +export interface ClaimOwnershipResult { + stats: { + tasksUpdated: number; + tasksConflicted: number; + tasksClaimed: number; + tasksRejected: number; + }; + docs: ConcreteTaskInstance[]; + timing?: TaskTiming; +} + +enum BatchConcurrency { + Unlimited, + Limited, +} + +type TaskClaimingBatches = Array; +interface TaskClaimingBatch { + concurrency: Concurrency; + tasksTypes: TaskType; +} +type UnlimitedBatch = TaskClaimingBatch>; +type LimitedBatch = TaskClaimingBatch; + +export class TaskClaiming { + public readonly errors$ = new Subject(); + public readonly maxAttempts: number; + + private definitions: TaskTypeDictionary; + private events$: Subject; + private taskStore: TaskStore; + private getCapacity: (taskType?: string) => number; + private logger: Logger; + private readonly taskClaimingBatchesByType: TaskClaimingBatches; + private readonly taskMaxAttempts: Record; + + /** + * Constructs a new TaskStore. + * @param {TaskClaimingOpts} opts + * @prop {number} maxAttempts - The maximum number of attempts before a task will be abandoned + * @prop {TaskDefinition} definition - The definition of the task being run + */ + constructor(opts: TaskClaimingOpts) { + this.definitions = opts.definitions; + this.maxAttempts = opts.maxAttempts; + this.taskStore = opts.taskStore; + this.getCapacity = opts.getCapacity; + this.logger = opts.logger; + this.taskClaimingBatchesByType = this.partitionIntoClaimingBatches(this.definitions); + this.taskMaxAttempts = Object.fromEntries(this.normalizeMaxAttempts(this.definitions)); + + this.events$ = new Subject(); + } + + private partitionIntoClaimingBatches(definitions: TaskTypeDictionary): TaskClaimingBatches { + const { + limitedConcurrency, + unlimitedConcurrency, + skippedTypes, + } = groupBy(definitions.getAllDefinitions(), (definition) => + definition.maxConcurrency + ? 'limitedConcurrency' + : definition.maxConcurrency === 0 + ? 'skippedTypes' + : 'unlimitedConcurrency' + ); + + if (skippedTypes?.length) { + this.logger.info( + `Task Manager will never claim tasks of the following types as their "maxConcurrency" is set to 0: ${skippedTypes + .map(({ type }) => type) + .join(', ')}` + ); + } + return [ + ...(unlimitedConcurrency + ? [asUnlimited(new Set(unlimitedConcurrency.map(({ type }) => type)))] + : []), + ...(limitedConcurrency ? limitedConcurrency.map(({ type }) => asLimited(type)) : []), + ]; + } + + private normalizeMaxAttempts(definitions: TaskTypeDictionary) { + return new Map( + [...definitions].map(([type, { maxAttempts }]) => [type, maxAttempts || this.maxAttempts]) + ); + } + + private claimingBatchIndex = 0; + private getClaimingBatches() { + // return all batches, starting at index and cycling back to where we began + const batch = [ + ...this.taskClaimingBatchesByType.slice(this.claimingBatchIndex), + ...this.taskClaimingBatchesByType.slice(0, this.claimingBatchIndex), + ]; + // shift claimingBatchIndex by one so that next cycle begins at the next index + this.claimingBatchIndex = (this.claimingBatchIndex + 1) % this.taskClaimingBatchesByType.length; + return batch; + } + + public get events(): Observable { + return this.events$; + } + + private emitEvents = (events: TaskClaim[]) => { + events.forEach((event) => this.events$.next(event)); + }; + + public claimAvailableTasksIfCapacityIsAvailable( + claimingOptions: Omit + ): Observable> { + if (this.getCapacity()) { + return this.claimAvailableTasks(claimingOptions).pipe( + map((claimResult) => asOk(claimResult)) + ); + } + this.logger.debug( + `[Task Ownership]: Task Manager has skipped Claiming Ownership of available tasks at it has ran out Available Workers.` + ); + return of(asErr(FillPoolResult.NoAvailableWorkers)); + } + + public claimAvailableTasks({ + claimOwnershipUntil, + claimTasksById = [], + }: Omit): Observable { + const initialCapacity = this.getCapacity(); + return from(this.getClaimingBatches()).pipe( + mergeScan( + (accumulatedResult, batch) => { + const stopTaskTimer = startTaskTimer(); + const capacity = Math.min( + initialCapacity - accumulatedResult.stats.tasksClaimed, + isLimited(batch) ? this.getCapacity(batch.tasksTypes) : this.getCapacity() + ); + // if we have no more capacity, short circuit here + if (capacity <= 0) { + return of(accumulatedResult); + } + return from( + this.executClaimAvailableTasks({ + claimOwnershipUntil, + claimTasksById: claimTasksById.splice(0, capacity), + size: capacity, + taskTypes: isLimited(batch) ? new Set([batch.tasksTypes]) : batch.tasksTypes, + }).then((result) => { + const { stats, docs } = accumulateClaimOwnershipResults(accumulatedResult, result); + stats.tasksConflicted = correctVersionConflictsForContinuation( + stats.tasksClaimed, + stats.tasksConflicted, + initialCapacity + ); + return { stats, docs, timing: stopTaskTimer() }; + }) + ); + }, + // initialise the accumulation with no results + accumulateClaimOwnershipResults(), + // only run one batch at a time + 1 + ) + ); + } + + private executClaimAvailableTasks = async ({ + claimOwnershipUntil, + claimTasksById = [], + size, + taskTypes, + }: OwnershipClaimingOpts): Promise => { + const claimTasksByIdWithRawIds = this.taskStore.convertToSavedObjectIds(claimTasksById); + const { + updated: tasksUpdated, + version_conflicts: tasksConflicted, + } = await this.markAvailableTasksAsClaimed({ + claimOwnershipUntil, + claimTasksById: claimTasksByIdWithRawIds, + size, + taskTypes, + }); + + const docs = + tasksUpdated > 0 + ? await this.sweepForClaimedTasks(claimTasksByIdWithRawIds, taskTypes, size) + : []; + + const [documentsReturnedById, documentsClaimedBySchedule] = partition(docs, (doc) => + claimTasksById.includes(doc.id) + ); + + const [documentsClaimedById, documentsRequestedButNotClaimed] = partition( + documentsReturnedById, + // we filter the schduled tasks down by status is 'claiming' in the esearch, + // but we do not apply this limitation on tasks claimed by ID so that we can + // provide more detailed error messages when we fail to claim them + (doc) => doc.status === TaskStatus.Claiming + ); + + // count how many tasks we've claimed by ID and validate we have capacity for them to run + const remainingCapacityOfClaimByIdByType = mapValues( + // This means we take the tasks that were claimed by their ID and count them by their type + countBy(documentsClaimedById, (doc) => doc.taskType), + (count, type) => this.getCapacity(type) - count + ); + + const [documentsClaimedByIdWithinCapacity, documentsClaimedByIdOutOfCapacity] = partition( + documentsClaimedById, + (doc) => { + // if we've exceeded capacity, we reject this task + if (remainingCapacityOfClaimByIdByType[doc.taskType] < 0) { + // as we're rejecting this task we can inc the count so that we know + // to keep the next one returned by ID of the same type + remainingCapacityOfClaimByIdByType[doc.taskType]++; + return false; + } + return true; + } + ); + + const documentsRequestedButNotReturned = difference( + claimTasksById, + documentsReturnedById.map((doc) => doc.id) + ); + + this.emitEvents([ + ...documentsClaimedByIdWithinCapacity.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), + ...documentsClaimedByIdOutOfCapacity.map((doc) => + asTaskClaimEvent( + doc.id, + asErr({ + task: some(doc), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY, + }) + ) + ), + ...documentsClaimedBySchedule.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), + ...documentsRequestedButNotClaimed.map((doc) => + asTaskClaimEvent( + doc.id, + asErr({ + task: some(doc), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS, + }) + ) + ), + ...documentsRequestedButNotReturned.map((id) => + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ), + ]); + + const stats = { + tasksUpdated, + tasksConflicted, + tasksRejected: documentsClaimedByIdOutOfCapacity.length, + tasksClaimed: documentsClaimedByIdWithinCapacity.length + documentsClaimedBySchedule.length, + }; + + if (docs.length !== stats.tasksClaimed + stats.tasksRejected) { + this.logger.warn( + `[Task Ownership error]: ${stats.tasksClaimed} tasks were claimed by Kibana, but ${ + docs.length + } task(s) were fetched (${docs.map((doc) => doc.id).join(', ')})` + ); + } + + return { + stats, + docs: [...documentsClaimedByIdWithinCapacity, ...documentsClaimedBySchedule], + }; + }; + + private async markAvailableTasksAsClaimed({ + claimOwnershipUntil, + claimTasksById, + size, + taskTypes, + }: OwnershipClaimingOpts): Promise { + const { taskTypesToSkip = [], taskTypesToClaim = [] } = groupBy( + this.definitions.getAllTypes(), + (type) => (taskTypes.has(type) ? 'taskTypesToClaim' : 'taskTypesToSkip') + ); + + const queryForScheduledTasks = mustBeAllOf( + // Either a task with idle status and runAt <= now or + // status running or claiming with a retryAt <= now. + shouldBeOneOf(IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt) + ); + + // The documents should be sorted by runAt/retryAt, unless there are pinned + // tasks being queried, in which case we want to sort by score first, and then + // the runAt/retryAt. That way we'll get the pinned tasks first. Note that + // the score seems to favor newer documents rather than older documents, so + // if there are not pinned tasks being queried, we do NOT want to sort by score + // at all, just by runAt/retryAt. + const sort: SortOptions = [SortByRunAtAndRetryAt]; + if (claimTasksById && claimTasksById.length) { + sort.unshift('_score'); + } + + const apmTrans = apm.startTransaction(`taskManager markAvailableTasksAsClaimed`, 'taskManager'); + const result = await this.taskStore.updateByQuery( + asUpdateByQuery({ + query: matchesClauses( + claimTasksById && claimTasksById.length + ? mustBeAllOf(asPinnedQuery(claimTasksById, queryForScheduledTasks)) + : queryForScheduledTasks, + filterDownBy(InactiveTasks) + ), + update: updateFieldsAndMarkAsFailed( + { + ownerId: this.taskStore.taskManagerId, + retryAt: claimOwnershipUntil, + }, + claimTasksById || [], + taskTypesToClaim, + taskTypesToSkip, + pick(this.taskMaxAttempts, taskTypesToClaim) + ), + sort, + }), + { + max_docs: size, + } + ); + + if (apmTrans) apmTrans.end(); + return result; + } + + /** + * Fetches tasks from the index, which are owned by the current Kibana instance + */ + private async sweepForClaimedTasks( + claimTasksById: OwnershipClaimingOpts['claimTasksById'], + taskTypes: Set, + size: number + ): Promise { + const claimedTasksQuery = tasksClaimedByOwner( + this.taskStore.taskManagerId, + tasksOfType([...taskTypes]) + ); + const { docs } = await this.taskStore.fetch({ + query: + claimTasksById && claimTasksById.length + ? asPinnedQuery(claimTasksById, claimedTasksQuery) + : claimedTasksQuery, + size, + sort: SortByRunAtAndRetryAt, + seq_no_primary_term: true, + }); + + return docs; + } +} + +const emptyClaimOwnershipResult = () => { + return { + stats: { + tasksUpdated: 0, + tasksConflicted: 0, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: [], + }; +}; + +function accumulateClaimOwnershipResults( + prev: ClaimOwnershipResult = emptyClaimOwnershipResult(), + next?: ClaimOwnershipResult +) { + if (next) { + const { stats, docs, timing } = next; + const res = { + stats: { + tasksUpdated: stats.tasksUpdated + prev.stats.tasksUpdated, + tasksConflicted: stats.tasksConflicted + prev.stats.tasksConflicted, + tasksClaimed: stats.tasksClaimed + prev.stats.tasksClaimed, + tasksRejected: stats.tasksRejected + prev.stats.tasksRejected, + }, + docs, + timing, + }; + return res; + } + return prev; +} + +function isLimited( + batch: TaskClaimingBatch +): batch is LimitedBatch { + return batch.concurrency === BatchConcurrency.Limited; +} +function asLimited(tasksType: string): LimitedBatch { + return { + concurrency: BatchConcurrency.Limited, + tasksTypes: tasksType, + }; +} +function asUnlimited(tasksTypes: Set): UnlimitedBatch { + return { + concurrency: BatchConcurrency.Unlimited, + tasksTypes, + }; +} diff --git a/x-pack/plugins/task_manager/server/task.ts b/x-pack/plugins/task_manager/server/task.ts index 04589d696427af..4b86943ff8eca2 100644 --- a/x-pack/plugins/task_manager/server/task.ts +++ b/x-pack/plugins/task_manager/server/task.ts @@ -127,6 +127,16 @@ export const taskDefinitionSchema = schema.object( min: 1, }) ), + /** + * The maximum number tasks of this type that can be run concurrently per Kibana instance. + * Setting this value will force Task Manager to poll for this task type seperatly from other task types + * which can add significant load to the ES cluster, so please use this configuration only when absolutly necesery. + */ + maxConcurrency: schema.maybe( + schema.number({ + min: 0, + }) + ), }, { validate({ timeout }) { diff --git a/x-pack/plugins/task_manager/server/task_events.ts b/x-pack/plugins/task_manager/server/task_events.ts index d3fb68aa367c1b..aecf7c9a2b7e89 100644 --- a/x-pack/plugins/task_manager/server/task_events.ts +++ b/x-pack/plugins/task_manager/server/task_events.ts @@ -23,6 +23,12 @@ export enum TaskEventType { TASK_MANAGER_STAT = 'TASK_MANAGER_STAT', } +export enum TaskClaimErrorType { + CLAIMED_BY_ID_OUT_OF_CAPACITY = 'CLAIMED_BY_ID_OUT_OF_CAPACITY', + CLAIMED_BY_ID_NOT_RETURNED = 'CLAIMED_BY_ID_NOT_RETURNED', + CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS = 'CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS', +} + export interface TaskTiming { start: number; stop: number; @@ -47,14 +53,18 @@ export interface RanTask { export type ErroredTask = RanTask & { error: Error; }; +export interface ClaimTaskErr { + task: Option; + errorType: TaskClaimErrorType; +} export type TaskMarkRunning = TaskEvent; export type TaskRun = TaskEvent; -export type TaskClaim = TaskEvent>; +export type TaskClaim = TaskEvent; export type TaskRunRequest = TaskEvent; export type TaskPollingCycle = TaskEvent>; -export type TaskManagerStats = 'load' | 'pollingDelay'; +export type TaskManagerStats = 'load' | 'pollingDelay' | 'claimDuration'; export type TaskManagerStat = TaskEvent; export type OkResultOf = EventType extends TaskEvent @@ -92,7 +102,7 @@ export function asTaskRunEvent( export function asTaskClaimEvent( id: string, - event: Result>, + event: Result, timing?: TaskTiming ): TaskClaim { return { diff --git a/x-pack/plugins/task_manager/server/task_pool.test.ts b/x-pack/plugins/task_manager/server/task_pool.test.ts index 6f82c477dca9e2..05eb7bd1b43e10 100644 --- a/x-pack/plugins/task_manager/server/task_pool.test.ts +++ b/x-pack/plugins/task_manager/server/task_pool.test.ts @@ -15,6 +15,7 @@ import { asOk } from './lib/result_type'; import { SavedObjectsErrorHelpers } from '../../../../src/core/server'; import moment from 'moment'; import uuid from 'uuid'; +import { TaskRunningStage } from './task_running'; describe('TaskPool', () => { test('occupiedWorkers are a sum of running tasks', async () => { @@ -370,6 +371,7 @@ describe('TaskPool', () => { cancel: async () => undefined, markTaskAsRunning: jest.fn(async () => true), run: mockRun(), + stage: TaskRunningStage.PENDING, toString: () => `TaskType "shooooo"`, get expiration() { return new Date(); diff --git a/x-pack/plugins/task_manager/server/task_pool.ts b/x-pack/plugins/task_manager/server/task_pool.ts index e30f9ef3154b2a..14c0c4581a15bb 100644 --- a/x-pack/plugins/task_manager/server/task_pool.ts +++ b/x-pack/plugins/task_manager/server/task_pool.ts @@ -25,6 +25,8 @@ interface Opts { } export enum TaskPoolRunResult { + // This mean we have no Run Result becuse no tasks were Ran in this cycle + NoTaskWereRan = 'NoTaskWereRan', // This means we're running all the tasks we claimed RunningAllClaimedTasks = 'RunningAllClaimedTasks', // This means we're running all the tasks we claimed and we're at capacity @@ -40,7 +42,7 @@ const VERSION_CONFLICT_MESSAGE = 'Task has been claimed by another Kibana servic */ export class TaskPool { private maxWorkers: number = 0; - private running = new Set(); + private tasksInPool = new Map(); private logger: Logger; private load$ = new Subject(); @@ -68,7 +70,7 @@ export class TaskPool { * Gets how many workers are currently in use. */ public get occupiedWorkers() { - return this.running.size; + return this.tasksInPool.size; } /** @@ -93,6 +95,16 @@ export class TaskPool { return this.maxWorkers - this.occupiedWorkers; } + /** + * Gets how many workers are currently in use by type. + */ + public getOccupiedWorkersByType(type: string) { + return [...this.tasksInPool.values()].reduce( + (count, runningTask) => (runningTask.definition.type === type ? ++count : count), + 0 + ); + } + /** * Attempts to run the specified list of tasks. Returns true if it was able * to start every task in the list, false if there was not enough capacity @@ -106,9 +118,11 @@ export class TaskPool { if (tasksToRun.length) { performance.mark('attemptToRun_start'); await Promise.all( - tasksToRun.map( - async (taskRunner) => - await taskRunner + tasksToRun + .filter((taskRunner) => !this.tasksInPool.has(taskRunner.id)) + .map(async (taskRunner) => { + this.tasksInPool.set(taskRunner.id, taskRunner); + return taskRunner .markTaskAsRunning() .then((hasTaskBeenMarkAsRunning: boolean) => hasTaskBeenMarkAsRunning @@ -118,8 +132,8 @@ export class TaskPool { message: VERSION_CONFLICT_MESSAGE, }) ) - .catch((err) => this.handleFailureOfMarkAsRunning(taskRunner, err)) - ) + .catch((err) => this.handleFailureOfMarkAsRunning(taskRunner, err)); + }) ); performance.mark('attemptToRun_stop'); @@ -139,13 +153,12 @@ export class TaskPool { public cancelRunningTasks() { this.logger.debug('Cancelling running tasks.'); - for (const task of this.running) { + for (const task of this.tasksInPool.values()) { this.cancelTask(task); } } private handleMarkAsRunning(taskRunner: TaskRunner) { - this.running.add(taskRunner); taskRunner .run() .catch((err) => { @@ -161,26 +174,31 @@ export class TaskPool { this.logger.warn(errorLogLine); } }) - .then(() => this.running.delete(taskRunner)); + .then(() => this.tasksInPool.delete(taskRunner.id)); } private handleFailureOfMarkAsRunning(task: TaskRunner, err: Error) { + this.tasksInPool.delete(task.id); this.logger.error(`Failed to mark Task ${task.toString()} as running: ${err.message}`); } private cancelExpiredTasks() { - for (const task of this.running) { - if (task.isExpired) { + for (const taskRunner of this.tasksInPool.values()) { + if (taskRunner.isExpired) { this.logger.warn( - `Cancelling task ${task.toString()} as it expired at ${task.expiration.toISOString()}${ - task.startedAt + `Cancelling task ${taskRunner.toString()} as it expired at ${taskRunner.expiration.toISOString()}${ + taskRunner.startedAt ? ` after running for ${durationAsString( - moment.duration(moment(new Date()).utc().diff(task.startedAt)) + moment.duration(moment(new Date()).utc().diff(taskRunner.startedAt)) )}` : `` - }${task.definition.timeout ? ` (with timeout set at ${task.definition.timeout})` : ``}.` + }${ + taskRunner.definition.timeout + ? ` (with timeout set at ${taskRunner.definition.timeout})` + : `` + }.` ); - this.cancelTask(task); + this.cancelTask(taskRunner); } } } @@ -188,7 +206,7 @@ export class TaskPool { private async cancelTask(task: TaskRunner) { try { this.logger.debug(`Cancelling task ${task.toString()}.`); - this.running.delete(task); + this.tasksInPool.delete(task.id); await task.cancel(); } catch (err) { this.logger.error(`Failed to cancel task ${task.toString()}: ${err}`); diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts index dff8c1f24de0ae..5a36d6affe686c 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import sinon from 'sinon'; import { secondsFromNow } from '../lib/intervals'; import { asOk, asErr } from '../lib/result_type'; -import { TaskManagerRunner, TaskRunResult } from '../task_running'; +import { TaskManagerRunner, TaskRunningStage, TaskRunResult } from '../task_running'; import { TaskEvent, asTaskRunEvent, asTaskMarkRunningEvent, TaskRun } from '../task_events'; import { ConcreteTaskInstance, TaskStatus } from '../task'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; @@ -17,6 +17,7 @@ import moment from 'moment'; import { TaskDefinitionRegistry, TaskTypeDictionary } from '../task_type_dictionary'; import { mockLogger } from '../test_utils'; import { throwUnrecoverableError } from './errors'; +import { taskStoreMock } from '../task_store.mock'; const minutesFromNow = (mins: number): Date => secondsFromNow(mins * 60); @@ -29,980 +30,834 @@ beforeAll(() => { afterAll(() => fakeTimer.restore()); describe('TaskManagerRunner', () => { - test('provides details about the task that is running', () => { - const { runner } = testOpts({ - instance: { - id: 'foo', - taskType: 'bar', - }, - }); + const pendingStageSetup = (opts: TestOpts) => testOpts(TaskRunningStage.PENDING, opts); + const readyToRunStageSetup = (opts: TestOpts) => testOpts(TaskRunningStage.READY_TO_RUN, opts); - expect(runner.id).toEqual('foo'); - expect(runner.taskType).toEqual('bar'); - expect(runner.toString()).toEqual('bar "foo"'); - }); - - test('queues a reattempt if the task fails', async () => { - const initialAttempts = _.random(0, 2); - const id = Date.now().toString(); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - params: { a: 'b' }, - state: { hey: 'there' }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - throw new Error('Dangit!'); - }, - }), + describe('Pending Stage', () => { + test('provides details about the task that is running', async () => { + const { runner } = await pendingStageSetup({ + instance: { + id: 'foo', + taskType: 'bar', }, - }, + }); + + expect(runner.id).toEqual('foo'); + expect(runner.taskType).toEqual('bar'); + expect(runner.toString()).toEqual('bar "foo"'); }); - await runner.run(); + test('calculates retryAt by schedule when running a recurring task', async () => { + const intervalMinutes = 10; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { + interval: `${intervalMinutes}m`, + }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await runner.markTaskAsRunning(); - expect(instance.id).toEqual(id); - expect(instance.runAt.getTime()).toEqual(minutesFromNow(initialAttempts * 5).getTime()); - expect(instance.params).toEqual({ a: 'b' }); - expect(instance.state).toEqual({ hey: 'there' }); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('reschedules tasks that have an schedule', async () => { - const { runner, store } = testOpts({ - instance: { - schedule: { interval: '10m' }, - status: TaskStatus.Running, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { state: {} }; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual( + instance.startedAt!.getTime() + intervalMinutes * 60 * 1000 + ); }); - await runner.run(); + test('calculates retryAt by default timout when it exceeds the schedule of a recurring task', async () => { + const intervalSeconds = 20; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { + interval: `${intervalSeconds}s`, + }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await runner.markTaskAsRunning(); - expect(instance.runAt.getTime()).toBeGreaterThan(minutesFromNow(9).getTime()); - expect(instance.runAt.getTime()).toBeLessThanOrEqual(minutesFromNow(10).getTime()); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('expiration returns time after which timeout will have elapsed from start', async () => { - const now = moment(); - const { runner } = testOpts({ - instance: { - schedule: { interval: '10m' }, - status: TaskStatus.Running, - startedAt: now.toDate(), - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `1m`, - createTaskRunner: () => ({ - async run() { - return { state: {} }; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual(instance.startedAt!.getTime() + 5 * 60 * 1000); }); - await runner.run(); - - expect(runner.isExpired).toBe(false); - expect(runner.expiration).toEqual(now.add(1, 'm').toDate()); - }); - - test('runDuration returns duration which has elapsed since start', async () => { - const now = moment().subtract(30, 's').toDate(); - const { runner } = testOpts({ - instance: { - schedule: { interval: '10m' }, - status: TaskStatus.Running, - startedAt: now, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `1m`, - createTaskRunner: () => ({ - async run() { - return { state: {} }; - }, - }), + test('calculates retryAt by timeout if it exceeds the schedule when running a recurring task', async () => { + const timeoutMinutes = 1; + const intervalSeconds = 20; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { + interval: `${intervalSeconds}s`, + }, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - expect(runner.isExpired).toBe(false); - expect(runner.startedAt).toEqual(now); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('reschedules tasks that return a runAt', async () => { - const runAt = minutesFromNow(_.random(1, 10)); - const { runner, store } = testOpts({ - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { runAt, state: {} }; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual( + instance.startedAt!.getTime() + timeoutMinutes * 60 * 1000 + ); }); - await runner.run(); - - sinon.assert.calledOnce(store.update); - sinon.assert.calledWithMatch(store.update, { runAt }); - }); - - test('reschedules tasks that return a schedule', async () => { - const runAt = minutesFromNow(1); - const schedule = { - interval: '1m', - }; - const { runner, store } = testOpts({ - instance: { - status: TaskStatus.Running, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { schedule, state: {} }; - }, - }), + test('sets startedAt, status, attempts and retryAt when claiming a task', async () => { + const timeoutMinutes = 1; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWithMatch(store.update, { runAt }); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test(`doesn't reschedule recurring tasks that throw an unrecoverable error`, async () => { - const id = _.random(1, 20).toString(); - const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, store, instance: originalInstance } = testOpts({ - onTaskEvent, - instance: { id, status: TaskStatus.Running, startedAt: new Date() }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - throwUnrecoverableError(error); - }, - }), - }, - }, + expect(instance.attempts).toEqual(initialAttempts + 1); + expect(instance.status).toBe('running'); + expect(instance.startedAt!.getTime()).toEqual(Date.now()); + expect(instance.retryAt!.getTime()).toEqual( + minutesFromNow((initialAttempts + 1) * 5).getTime() + timeoutMinutes * 60 * 1000 + ); }); - await runner.run(); - - const instance = store.update.args[0][0]; - expect(instance.status).toBe('failed'); - - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent( + test('uses getRetry (returning date) to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { id, - asErr({ - error, - task: originalInstance, - result: TaskRunResult.Failed, - }) - ) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); - }); - - test('tasks that return runAt override the schedule', async () => { - const runAt = minutesFromNow(_.random(5)); - const { runner, store } = testOpts({ - instance: { - schedule: { interval: '20m' }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { runAt, state: {} }; - }, - }), + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWithMatch(store.update, { runAt }); - }); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts + 1); + const instance = store.update.mock.calls[0][0]; - test('removes non-recurring tasks after they complete', async () => { - const id = _.random(1, 20).toString(); - const { runner, store } = testOpts({ - instance: { - id, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return undefined; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual( + new Date(nextRetry.getTime() + timeoutMinutes * 60 * 1000).getTime() + ); }); - await runner.run(); - - sinon.assert.calledOnce(store.remove); - sinon.assert.calledWith(store.remove, id); - }); - - test('cancel cancels the task runner, if it is cancellable', async () => { - let wasCancelled = false; - const { runner, logger } = testOpts({ - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - const promise = new Promise((r) => setTimeout(r, 1000)); - fakeTimer.tick(1000); - await promise; - }, - async cancel() { - wasCancelled = true; - }, - }), + test('it returns false when markTaskAsRunning fails due to VERSION_CONFLICT_STATUS', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - const promise = runner.run(); - await Promise.resolve(); - await runner.cancel(); - await promise; + store.update.mockRejectedValue( + SavedObjectsErrorHelpers.decorateConflictError(new Error('repo error')) + ); - expect(wasCancelled).toBeTruthy(); - expect(logger.warn).not.toHaveBeenCalled(); - }); + expect(await runner.markTaskAsRunning()).toEqual(false); + }); - test('debug logs if cancel is called on a non-cancellable task', async () => { - const { runner, logger } = testOpts({ - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - run: async () => undefined, - }), + test('it throw when markTaskAsRunning fails for unexpected reasons', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - const promise = runner.run(); - await runner.cancel(); - await promise; + store.update.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id') + ); - expect(logger.debug).toHaveBeenCalledWith(`The task bar "foo" is not cancellable.`); - }); + return expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( + `[Error: Saved object [type/id] not found]` + ); + }); - test('sets startedAt, status, attempts and retryAt when claiming a task', async () => { - const timeoutMinutes = 1; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - createTaskRunner: () => ({ - run: async () => undefined, - }), + test(`it tries to increment a task's attempts when markTaskAsRunning fails for unexpected reasons`, async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.markTaskAsRunning(); + store.update.mockRejectedValueOnce(SavedObjectsErrorHelpers.createBadRequestError('type')); + store.update.mockResolvedValueOnce( + mockInstance({ + id, + attempts: initialAttempts, + schedule: undefined, + }) + ); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( + `[Error: type: Bad Request]` + ); - expect(instance.attempts).toEqual(initialAttempts + 1); - expect(instance.status).toBe('running'); - expect(instance.startedAt.getTime()).toEqual(Date.now()); - expect(instance.retryAt.getTime()).toEqual( - minutesFromNow((initialAttempts + 1) * 5).getTime() + timeoutMinutes * 60 * 1000 - ); - }); + expect(store.update).toHaveBeenCalledWith({ + ...mockInstance({ + id, + attempts: initialAttempts + 1, + schedule: undefined, + }), + status: TaskStatus.Idle, + startedAt: null, + retryAt: null, + ownerId: null, + }); + }); - test('calculates retryAt by schedule when running a recurring task', async () => { - const intervalMinutes = 10; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { - interval: `${intervalMinutes}m`, + test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails for version conflict`, async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - run: async () => undefined, - }), + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, }, - }, - }); + }); - await runner.markTaskAsRunning(); + store.update.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createConflictError('type', 'id') + ); + store.update.mockResolvedValueOnce( + mockInstance({ + id, + attempts: initialAttempts, + schedule: undefined, + }) + ); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await expect(runner.markTaskAsRunning()).resolves.toMatchInlineSnapshot(`false`); - expect(instance.retryAt.getTime()).toEqual( - instance.startedAt.getTime() + intervalMinutes * 60 * 1000 - ); - }); + expect(store.update).toHaveBeenCalledTimes(1); + }); - test('calculates retryAt by default timout when it exceeds the schedule of a recurring task', async () => { - const intervalSeconds = 20; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { - interval: `${intervalSeconds}s`, + test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails due to Saved Object not being found`, async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - run: async () => undefined, - }), + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, }, - }, - }); + }); - await runner.markTaskAsRunning(); + store.update.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id') + ); + store.update.mockResolvedValueOnce( + mockInstance({ + id, + attempts: initialAttempts, + schedule: undefined, + }) + ); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( + `[Error: Saved object [type/id] not found]` + ); - expect(instance.retryAt.getTime()).toEqual(instance.startedAt.getTime() + 5 * 60 * 1000); - }); + expect(store.update).toHaveBeenCalledTimes(1); + }); - test('calculates retryAt by timeout if it exceeds the schedule when running a recurring task', async () => { - const timeoutMinutes = 1; - const intervalSeconds = 20; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { - interval: `${intervalSeconds}s`, + test('uses getRetry (returning true) to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(true); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - createTaskRunner: () => ({ - run: async () => undefined, - }), + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, }, - }, - }); + }); - await runner.markTaskAsRunning(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts + 1); + const instance = store.update.mock.calls[0][0]; - expect(instance.retryAt.getTime()).toEqual( - instance.startedAt.getTime() + timeoutMinutes * 60 * 1000 - ); - }); + const attemptDelay = (initialAttempts + 1) * 5 * 60 * 1000; + const timeoutDelay = timeoutMinutes * 60 * 1000; + expect(instance.retryAt!.getTime()).toEqual( + new Date(Date.now() + attemptDelay + timeoutDelay).getTime() + ); + }); - test('uses getRetry function (returning date) on error when defined', async () => { - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(nextRetry); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; - }, - }), + test('uses getRetry (returning false) to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(false); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts, error); - const instance = store.update.args[0][0]; + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts + 1); + const instance = store.update.mock.calls[0][0]; - expect(instance.runAt.getTime()).toEqual(nextRetry.getTime()); - }); + expect(instance.retryAt!).toBeNull(); + expect(instance.status).toBe('running'); + }); - test('uses getRetry function (returning true) on error when defined', async () => { - const initialAttempts = _.random(1, 3); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(true); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; - }, - }), + test('bypasses getRetry (returning false) of a recurring task to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(false); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { interval: '1m' }, + startedAt: new Date(), }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts, error); - const instance = store.update.args[0][0]; + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.notCalled(getRetryStub); + const instance = store.update.mock.calls[0][0]; - const expectedRunAt = new Date(Date.now() + initialAttempts * 5 * 60 * 1000); - expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); - }); + const timeoutDelay = timeoutMinutes * 60 * 1000; + expect(instance.retryAt!.getTime()).toEqual(new Date(Date.now() + timeoutDelay).getTime()); + }); - test('uses getRetry function (returning false) on error when defined', async () => { - const initialAttempts = _.random(1, 3); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(false); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; + describe('TaskEvents', () => { + test('emits TaskEvent when a task is marked as running', async () => { + const id = _.random(1, 20).toString(); + const onTaskEvent = jest.fn(); + const { runner, instance, store } = await pendingStageSetup({ + onTaskEvent, + instance: { + id, + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), }, - }), - }, - }, - }); + }, + }); - await runner.run(); + store.update.mockResolvedValueOnce(instance); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts, error); - const instance = store.update.args[0][0]; + await runner.markTaskAsRunning(); - expect(instance.status).toBe('failed'); - }); + expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asOk(instance))); + }); - test('bypasses getRetry function (returning false) on error of a recurring task', async () => { - const initialAttempts = _.random(1, 3); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(false); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { interval: '1m' }, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; - }, - }), - }, - }, - }); + test('emits TaskEvent when a task fails to be marked as running', async () => { + expect.assertions(2); - await runner.run(); + const id = _.random(1, 20).toString(); + const onTaskEvent = jest.fn(); + const { runner, store } = await pendingStageSetup({ + onTaskEvent, + instance: { + id, + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.notCalled(getRetryStub); - const instance = store.update.args[0][0]; + store.update.mockRejectedValueOnce(new Error('cant mark as running')); - const nextIntervalDelay = 60000; // 1m - const expectedRunAt = new Date(Date.now() + nextIntervalDelay); - expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); + try { + await runner.markTaskAsRunning(); + } catch (err) { + expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asErr(err))); + } + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); + }); }); - test('uses getRetry (returning date) to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), + describe('Ready To Run Stage', () => { + test('queues a reattempt if the task fails', async () => { + const initialAttempts = _.random(0, 2); + const id = Date.now().toString(); + const { runner, store } = await readyToRunStageSetup({ + instance: { + id, + attempts: initialAttempts, + params: { a: 'b' }, + state: { hey: 'there' }, }, - }, - }); - - await runner.markTaskAsRunning(); + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throw new Error('Dangit!'); + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts + 1); - const instance = store.update.args[0][0]; + await runner.run(); - expect(instance.retryAt.getTime()).toEqual( - new Date(nextRetry.getTime() + timeoutMinutes * 60 * 1000).getTime() - ); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('it returns false when markTaskAsRunning fails due to VERSION_CONFLICT_STATUS', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(instance.id).toEqual(id); + expect(instance.runAt.getTime()).toEqual(minutesFromNow(initialAttempts * 5).getTime()); + expect(instance.params).toEqual({ a: 'b' }); + expect(instance.state).toEqual({ hey: 'there' }); }); - store.update = sinon - .stub() - .throws(SavedObjectsErrorHelpers.decorateConflictError(new Error('repo error'))); - - expect(await runner.markTaskAsRunning()).toEqual(false); - }); - - test('it throw when markTaskAsRunning fails for unexpected reasons', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), + test('reschedules tasks that have an schedule', async () => { + const { runner, store } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '10m' }, + status: TaskStatus.Running, + startedAt: new Date(), }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); - store.update = sinon - .stub() - .throws(SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id')); + await runner.run(); - return expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( - `[Error: Saved object [type/id] not found]` - ); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test(`it tries to increment a task's attempts when markTaskAsRunning fails for unexpected reasons`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(instance.runAt.getTime()).toBeGreaterThan(minutesFromNow(9).getTime()); + expect(instance.runAt.getTime()).toBeLessThanOrEqual(minutesFromNow(10).getTime()); }); - store.update = sinon.stub(); - store.update.onFirstCall().throws(SavedObjectsErrorHelpers.createBadRequestError('type')); - store.update.onSecondCall().resolves(); + test('expiration returns time after which timeout will have elapsed from start', async () => { + const now = moment(); + const { runner } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '10m' }, + status: TaskStatus.Running, + startedAt: now.toDate(), + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); - await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( - `[Error: type: Bad Request]` - ); + await runner.run(); - sinon.assert.calledWith(store.update, { - ...mockInstance({ - id, - attempts: initialAttempts + 1, - schedule: undefined, - }), - status: TaskStatus.Idle, - startedAt: null, - retryAt: null, - ownerId: null, + expect(runner.isExpired).toBe(false); + expect(runner.expiration).toEqual(now.add(1, 'm').toDate()); }); - }); - test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails for version conflict`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), + test('runDuration returns duration which has elapsed since start', async () => { + const now = moment().subtract(30, 's').toDate(); + const { runner } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '10m' }, + status: TaskStatus.Running, + startedAt: now, }, - }, - }); - - store.update = sinon.stub(); - store.update.onFirstCall().throws(SavedObjectsErrorHelpers.createConflictError('type', 'id')); - store.update.onSecondCall().resolves(); - - await expect(runner.markTaskAsRunning()).resolves.toMatchInlineSnapshot(`false`); + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - }); + await runner.run(); - test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails due to Saved Object not being found`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(runner.isExpired).toBe(false); + expect(runner.startedAt).toEqual(now); }); - store.update = sinon.stub(); - store.update - .onFirstCall() - .throws(SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id')); - store.update.onSecondCall().resolves(); - - await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( - `[Error: Saved object [type/id] not found]` - ); + test('reschedules tasks that return a runAt', async () => { + const runAt = minutesFromNow(_.random(1, 10)); + const { runner, store } = await readyToRunStageSetup({ + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { runAt, state: {} }; + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - }); + await runner.run(); - test('uses getRetry (returning true) to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(true); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt })); }); - await runner.markTaskAsRunning(); - - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts + 1); - const instance = store.update.args[0][0]; + test('reschedules tasks that return a schedule', async () => { + const runAt = minutesFromNow(1); + const schedule = { + interval: '1m', + }; + const { runner, store } = await readyToRunStageSetup({ + instance: { + status: TaskStatus.Running, + startedAt: new Date(), + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { schedule, state: {} }; + }, + }), + }, + }, + }); - const attemptDelay = (initialAttempts + 1) * 5 * 60 * 1000; - const timeoutDelay = timeoutMinutes * 60 * 1000; - expect(instance.retryAt.getTime()).toEqual( - new Date(Date.now() + attemptDelay + timeoutDelay).getTime() - ); - }); + await runner.run(); - test('uses getRetry (returning false) to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(false); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt })); }); - await runner.markTaskAsRunning(); + test(`doesn't reschedule recurring tasks that throw an unrecoverable error`, async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, store, instance: originalInstance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { id, status: TaskStatus.Running, startedAt: new Date() }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throwUnrecoverableError(error); + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts + 1); - const instance = store.update.args[0][0]; + await runner.run(); - expect(instance.retryAt).toBeNull(); - expect(instance.status).toBe('running'); - }); + const instance = store.update.mock.calls[0][0]; + expect(instance.status).toBe('failed'); - test('bypasses getRetry (returning false) of a recurring task to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(false); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { interval: '1m' }, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ + error, + task: originalInstance, + result: TaskRunResult.Failed, + }) + ) + ) + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); }); - await runner.markTaskAsRunning(); + test('tasks that return runAt override the schedule', async () => { + const runAt = minutesFromNow(_.random(5)); + const { runner, store } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '20m' }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { runAt, state: {} }; + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.notCalled(getRetryStub); - const instance = store.update.args[0][0]; + await runner.run(); - const timeoutDelay = timeoutMinutes * 60 * 1000; - expect(instance.retryAt.getTime()).toEqual(new Date(Date.now() + timeoutDelay).getTime()); - }); + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt })); + }); - test('Fails non-recurring task when maxAttempts reached', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = 3; - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - maxAttempts: 3, - createTaskRunner: () => ({ - run: async () => { - throw new Error(); - }, - }), + test('removes non-recurring tasks after they complete', async () => { + const id = _.random(1, 20).toString(); + const { runner, store } = await readyToRunStageSetup({ + instance: { + id, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return undefined; + }, + }), + }, + }, + }); - await runner.run(); + await runner.run(); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; - expect(instance.attempts).toEqual(3); - expect(instance.status).toEqual('failed'); - expect(instance.retryAt).toBeNull(); - expect(instance.runAt.getTime()).toBeLessThanOrEqual(Date.now()); - }); + expect(store.remove).toHaveBeenCalledTimes(1); + expect(store.remove).toHaveBeenCalledWith(id); + }); - test(`Doesn't fail recurring tasks when maxAttempts reached`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = 3; - const intervalSeconds = 10; - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { interval: `${intervalSeconds}s` }, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - maxAttempts: 3, - createTaskRunner: () => ({ - run: async () => { - throw new Error(); - }, - }), + test('cancel cancels the task runner, if it is cancellable', async () => { + let wasCancelled = false; + const { runner, logger } = await readyToRunStageSetup({ + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + const promise = new Promise((r) => setTimeout(r, 1000)); + fakeTimer.tick(1000); + await promise; + }, + async cancel() { + wasCancelled = true; + }, + }), + }, }, - }, - }); + }); - await runner.run(); + const promise = runner.run(); + await Promise.resolve(); + await runner.cancel(); + await promise; - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; - expect(instance.attempts).toEqual(3); - expect(instance.status).toEqual('idle'); - expect(instance.runAt.getTime()).toEqual( - new Date(Date.now() + intervalSeconds * 1000).getTime() - ); - }); + expect(wasCancelled).toBeTruthy(); + expect(logger.warn).not.toHaveBeenCalled(); + }); - describe('TaskEvents', () => { - test('emits TaskEvent when a task is marked as running', async () => { - const id = _.random(1, 20).toString(); - const onTaskEvent = jest.fn(); - const { runner, instance, store } = testOpts({ - onTaskEvent, - instance: { - id, - }, + test('debug logs if cancel is called on a non-cancellable task', async () => { + const { runner, logger } = await readyToRunStageSetup({ definitions: { bar: { title: 'Bar!', - timeout: `1m`, createTaskRunner: () => ({ run: async () => undefined, }), @@ -1010,58 +865,63 @@ describe('TaskManagerRunner', () => { }, }); - store.update.returns(instance); + const promise = runner.run(); + await runner.cancel(); + await promise; - await runner.markTaskAsRunning(); - - expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asOk(instance))); + expect(logger.debug).toHaveBeenCalledWith(`The task bar "foo" is not cancellable.`); }); - test('emits TaskEvent when a task fails to be marked as running', async () => { - expect.assertions(2); - - const id = _.random(1, 20).toString(); - const onTaskEvent = jest.fn(); - const { runner, store } = testOpts({ - onTaskEvent, + test('uses getRetry function (returning date) on error when defined', async () => { + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(nextRetry); + const error = new Error('Dangit!'); + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, }, definitions: { bar: { title: 'Bar!', - timeout: `1m`, + getRetry: getRetryStub, createTaskRunner: () => ({ - run: async () => undefined, + async run() { + throw error; + }, }), }, }, }); - store.update.throws(new Error('cant mark as running')); + await runner.run(); - try { - await runner.markTaskAsRunning(); - } catch (err) { - expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asErr(err))); - } - expect(onTaskEvent).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts, error); + const instance = store.update.mock.calls[0][0]; + + expect(instance.runAt.getTime()).toEqual(nextRetry.getTime()); }); - test('emits TaskEvent when a task is run successfully', async () => { - const id = _.random(1, 20).toString(); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + test('uses getRetry function (returning true) on error when defined', async () => { + const initialAttempts = _.random(1, 3); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(true); + const error = new Error('Dangit!'); + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, }, definitions: { bar: { title: 'Bar!', + getRetry: getRetryStub, createTaskRunner: () => ({ async run() { - return { state: {} }; + throw error; }, }), }, @@ -1070,27 +930,31 @@ describe('TaskManagerRunner', () => { await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) - ); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts, error); + const instance = store.update.mock.calls[0][0]; + + const expectedRunAt = new Date(Date.now() + initialAttempts * 5 * 60 * 1000); + expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); }); - test('emits TaskEvent when a recurring task is run successfully', async () => { - const id = _.random(1, 20).toString(); - const runAt = minutesFromNow(_.random(5)); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + test('uses getRetry function (returning false) on error when defined', async () => { + const initialAttempts = _.random(1, 3); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(false); + const error = new Error('Dangit!'); + const { runner, store } = await readyToRunStageSetup({ instance: { id, - schedule: { interval: '1m' }, + attempts: initialAttempts, }, definitions: { bar: { title: 'Bar!', + getRetry: getRetryStub, createTaskRunner: () => ({ async run() { - return { runAt, state: {} }; + throw error; }, }), }, @@ -1099,23 +963,29 @@ describe('TaskManagerRunner', () => { await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) - ); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts, error); + const instance = store.update.mock.calls[0][0]; + + expect(instance.status).toBe('failed'); }); - test('emits TaskEvent when a task run throws an error', async () => { - const id = _.random(1, 20).toString(); + test('bypasses getRetry function (returning false) on error of a recurring task', async () => { + const initialAttempts = _.random(1, 3); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(false); const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, + schedule: { interval: '1m' }, + startedAt: new Date(), }, definitions: { bar: { title: 'Bar!', + getRetry: getRetryStub, createTaskRunner: () => ({ async run() { throw error; @@ -1124,33 +994,34 @@ describe('TaskManagerRunner', () => { }, }, }); + await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent(id, asErr({ error, task: instance, result: TaskRunResult.RetryScheduled })) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.notCalled(getRetryStub); + const instance = store.update.mock.calls[0][0]; + + const nextIntervalDelay = 60000; // 1m + const expectedRunAt = new Date(Date.now() + nextIntervalDelay); + expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); }); - test('emits TaskEvent when a task run returns an error', async () => { + test('Fails non-recurring task when maxAttempts reached', async () => { const id = _.random(1, 20).toString(); - const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + const initialAttempts = 3; + const { runner, store } = await readyToRunStageSetup({ instance: { id, - schedule: { interval: '1m' }, - startedAt: new Date(), + attempts: initialAttempts, + schedule: undefined, }, definitions: { bar: { title: 'Bar!', + maxAttempts: 3, createTaskRunner: () => ({ - async run() { - return { error, state: {} }; + run: async () => { + throw new Error(); }, }), }, @@ -1159,31 +1030,32 @@ describe('TaskManagerRunner', () => { await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent(id, asErr({ error, task: instance, result: TaskRunResult.RetryScheduled })) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; + expect(instance.attempts).toEqual(3); + expect(instance.status).toEqual('failed'); + expect(instance.retryAt!).toBeNull(); + expect(instance.runAt.getTime()).toBeLessThanOrEqual(Date.now()); }); - test('emits TaskEvent when a task returns an error and is marked as failed', async () => { + test(`Doesn't fail recurring tasks when maxAttempts reached`, async () => { const id = _.random(1, 20).toString(); - const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, store, instance: originalInstance } = testOpts({ - onTaskEvent, + const initialAttempts = 3; + const intervalSeconds = 10; + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, + schedule: { interval: `${intervalSeconds}s` }, startedAt: new Date(), }, definitions: { bar: { title: 'Bar!', - getRetry: () => false, + maxAttempts: 3, createTaskRunner: () => ({ - async run() { - return { error, state: {} }; + run: async () => { + throw new Error(); }, }), }, @@ -1192,29 +1064,190 @@ describe('TaskManagerRunner', () => { await runner.run(); - const instance = store.update.args[0][0]; - expect(instance.status).toBe('failed'); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; + expect(instance.attempts).toEqual(3); + expect(instance.status).toEqual('idle'); + expect(instance.runAt.getTime()).toEqual( + new Date(Date.now() + intervalSeconds * 1000).getTime() + ); + }); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent( + describe('TaskEvents', () => { + test('emits TaskEvent when a task is run successfully', async () => { + const id = _.random(1, 20).toString(); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { id, - asErr({ - error, - task: originalInstance, - result: TaskRunResult.Failed, - }) + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) + ); + }); + + test('emits TaskEvent when a recurring task is run successfully', async () => { + const id = _.random(1, 20).toString(); + const runAt = minutesFromNow(_.random(5)); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + schedule: { interval: '1m' }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { runAt, state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) + ); + }); + + test('emits TaskEvent when a task run throws an error', async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throw error; + }, + }), + }, + }, + }); + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ error, task: instance, result: TaskRunResult.RetryScheduled }) + ) ) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); + + test('emits TaskEvent when a task run returns an error', async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + schedule: { interval: '1m' }, + startedAt: new Date(), + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { error, state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ error, task: instance, result: TaskRunResult.RetryScheduled }) + ) + ) + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); + + test('emits TaskEvent when a task returns an error and is marked as failed', async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, store, instance: originalInstance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + startedAt: new Date(), + }, + definitions: { + bar: { + title: 'Bar!', + getRetry: () => false, + createTaskRunner: () => ({ + async run() { + return { error, state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + const instance = store.update.mock.calls[0][0]; + expect(instance.status).toBe('failed'); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ + error, + task: originalInstance, + result: TaskRunResult.Failed, + }) + ) + ) + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); }); }); interface TestOpts { instance?: Partial; definitions?: TaskDefinitionRegistry; - onTaskEvent?: (event: TaskEvent) => void; + onTaskEvent?: jest.Mock<(event: TaskEvent) => void>; } function withAnyTiming(taskRun: TaskRun) { @@ -1247,20 +1280,16 @@ describe('TaskManagerRunner', () => { ); } - function testOpts(opts: TestOpts) { + async function testOpts(stage: TaskRunningStage, opts: TestOpts) { const callCluster = sinon.stub(); const createTaskRunner = sinon.stub(); const logger = mockLogger(); const instance = mockInstance(opts.instance); - const store = { - update: sinon.stub(), - remove: sinon.stub(), - maxAttempts: 5, - }; + const store = taskStoreMock.create(); - store.update.returns(instance); + store.update.mockResolvedValue(instance); const definitions = new TaskTypeDictionary(logger); definitions.registerTaskDefinitions({ @@ -1274,6 +1303,7 @@ describe('TaskManagerRunner', () => { } const runner = new TaskManagerRunner({ + defaultMaxAttempts: 5, beforeRun: (context) => Promise.resolve(context), beforeMarkRunning: (context) => Promise.resolve(context), logger, @@ -1283,6 +1313,15 @@ describe('TaskManagerRunner', () => { onTaskEvent: opts.onTaskEvent, }); + if (stage === TaskRunningStage.READY_TO_RUN) { + await runner.markTaskAsRunning(); + // as we're testing the ReadyToRun stage specifically, clear mocks cakked by setup + store.update.mockClear(); + if (opts.onTaskEvent) { + opts.onTaskEvent.mockClear(); + } + } + return { callCluster, createTaskRunner, diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index ad5a2e11409ec8..8e061eae460280 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -63,11 +63,22 @@ export interface TaskRunner { markTaskAsRunning: () => Promise; run: () => Promise>; id: string; + stage: string; toString: () => string; } +export enum TaskRunningStage { + PENDING = 'PENDING', + READY_TO_RUN = 'READY_TO_RUN', + RAN = 'RAN', +} +export interface TaskRunning { + timestamp: Date; + stage: Stage; + task: Instance; +} + export interface Updatable { - readonly maxAttempts: number; update(doc: ConcreteTaskInstance): Promise; remove(id: string): Promise; } @@ -78,6 +89,7 @@ type Opts = { instance: ConcreteTaskInstance; store: Updatable; onTaskEvent?: (event: TaskRun | TaskMarkRunning) => void; + defaultMaxAttempts: number; } & Pick; export enum TaskRunResult { @@ -91,6 +103,16 @@ export enum TaskRunResult { Failed = 'Failed', } +// A ConcreteTaskInstance which we *know* has a `startedAt` Date on it +type ConcreteTaskInstanceWithStartedAt = ConcreteTaskInstance & { startedAt: Date }; + +// The three possible stages for a Task Runner - Pending -> ReadyToRun -> Ran +type PendingTask = TaskRunning; +type ReadyToRunTask = TaskRunning; +type RanTask = TaskRunning; + +type TaskRunningInstance = PendingTask | ReadyToRunTask | RanTask; + /** * Runs a background task, ensures that errors are properly handled, * allows for cancellation. @@ -101,13 +123,14 @@ export enum TaskRunResult { */ export class TaskManagerRunner implements TaskRunner { private task?: CancellableTask; - private instance: ConcreteTaskInstance; + private instance: TaskRunningInstance; private definitions: TaskTypeDictionary; private logger: Logger; private bufferedTaskStore: Updatable; private beforeRun: Middleware['beforeRun']; private beforeMarkRunning: Middleware['beforeMarkRunning']; private onTaskEvent: (event: TaskRun | TaskMarkRunning) => void; + private defaultMaxAttempts: number; /** * Creates an instance of TaskManagerRunner. @@ -126,29 +149,38 @@ export class TaskManagerRunner implements TaskRunner { store, beforeRun, beforeMarkRunning, + defaultMaxAttempts, onTaskEvent = identity, }: Opts) { - this.instance = sanitizeInstance(instance); + this.instance = asPending(sanitizeInstance(instance)); this.definitions = definitions; this.logger = logger; this.bufferedTaskStore = store; this.beforeRun = beforeRun; this.beforeMarkRunning = beforeMarkRunning; this.onTaskEvent = onTaskEvent; + this.defaultMaxAttempts = defaultMaxAttempts; } /** * Gets the id of this task instance. */ public get id() { - return this.instance.id; + return this.instance.task.id; } /** * Gets the task type of this task instance. */ public get taskType() { - return this.instance.taskType; + return this.instance.task.taskType; + } + + /** + * Get the stage this TaskRunner is at + */ + public get stage() { + return this.instance.stage; } /** @@ -162,14 +194,21 @@ export class TaskManagerRunner implements TaskRunner { * Gets the time at which this task will expire. */ public get expiration() { - return intervalFromDate(this.instance.startedAt!, this.definition.timeout)!; + return intervalFromDate( + // if the task is running, use it's started at, otherwise use the timestamp at + // which it was last updated + // this allows us to catch tasks that remain in Pending/Finalizing without being + // cleaned up + isReadyToRun(this.instance) ? this.instance.task.startedAt : this.instance.timestamp, + this.definition.timeout + )!; } /** * Gets the duration of the current task run */ public get startedAt() { - return this.instance.startedAt; + return this.instance.task.startedAt; } /** @@ -195,9 +234,16 @@ export class TaskManagerRunner implements TaskRunner { * @returns {Promise>} */ public async run(): Promise> { + if (!isReadyToRun(this.instance)) { + throw new Error( + `Running task ${this} failed as it ${ + isPending(this.instance) ? `isn't ready to be ran` : `has already been ran` + }` + ); + } this.logger.debug(`Running task ${this}`); const modifiedContext = await this.beforeRun({ - taskInstance: this.instance, + taskInstance: this.instance.task, }); const stopTaskTimer = startTaskTimer(); @@ -230,10 +276,16 @@ export class TaskManagerRunner implements TaskRunner { * @returns {Promise} */ public async markTaskAsRunning(): Promise { + if (!isPending(this.instance)) { + throw new Error( + `Marking task ${this} as running has failed as it ${ + isReadyToRun(this.instance) ? `is already running` : `has already been ran` + }` + ); + } performance.mark('markTaskAsRunning_start'); const apmTrans = apm.startTransaction(`taskManager markTaskAsRunning`, 'taskManager'); - apmTrans?.addLabels({ taskType: this.taskType, }); @@ -241,7 +293,7 @@ export class TaskManagerRunner implements TaskRunner { const now = new Date(); try { const { taskInstance } = await this.beforeMarkRunning({ - taskInstance: this.instance, + taskInstance: this.instance.task, }); const attempts = taskInstance.attempts + 1; @@ -258,22 +310,29 @@ export class TaskManagerRunner implements TaskRunner { ); } - this.instance = await this.bufferedTaskStore.update({ - ...taskInstance, - status: TaskStatus.Running, - startedAt: now, - attempts, - retryAt: - (this.instance.schedule - ? maxIntervalFromDate(now, this.instance.schedule!.interval, this.definition.timeout) - : this.getRetryDelay({ - attempts, - // Fake an error. This allows retry logic when tasks keep timing out - // and lets us set a proper "retryAt" value each time. - error: new Error('Task timeout'), - addDuration: this.definition.timeout, - })) ?? null, - }); + this.instance = asReadyToRun( + (await this.bufferedTaskStore.update({ + ...taskInstance, + status: TaskStatus.Running, + startedAt: now, + attempts, + retryAt: + (this.instance.task.schedule + ? maxIntervalFromDate( + now, + this.instance.task.schedule.interval, + this.definition.timeout + ) + : this.getRetryDelay({ + attempts, + // Fake an error. This allows retry logic when tasks keep timing out + // and lets us set a proper "retryAt" value each time. + error: new Error('Task timeout'), + addDuration: this.definition.timeout, + })) ?? null, + // This is a safe convertion as we're setting the startAt above + })) as ConcreteTaskInstanceWithStartedAt + ); const timeUntilClaimExpiresAfterUpdate = howManyMsUntilOwnershipClaimExpires( ownershipClaimedUntil @@ -288,7 +347,7 @@ export class TaskManagerRunner implements TaskRunner { if (apmTrans) apmTrans.end('success'); performanceStopMarkingTaskAsRunning(); - this.onTaskEvent(asTaskMarkRunningEvent(this.id, asOk(this.instance))); + this.onTaskEvent(asTaskMarkRunningEvent(this.id, asOk(this.instance.task))); return true; } catch (error) { if (apmTrans) apmTrans.end('failure'); @@ -299,7 +358,7 @@ export class TaskManagerRunner implements TaskRunner { // try to release claim as an unknown failure prevented us from marking as running mapErr((errReleaseClaim: Error) => { this.logger.error( - `[Task Runner] Task ${this.instance.id} failed to release claim after failure: ${errReleaseClaim}` + `[Task Runner] Task ${this.id} failed to release claim after failure: ${errReleaseClaim}` ); }, await this.releaseClaimAndIncrementAttempts()); } @@ -336,9 +395,9 @@ export class TaskManagerRunner implements TaskRunner { private async releaseClaimAndIncrementAttempts(): Promise> { return promiseResult( this.bufferedTaskStore.update({ - ...this.instance, + ...this.instance.task, status: TaskStatus.Idle, - attempts: this.instance.attempts + 1, + attempts: this.instance.task.attempts + 1, startedAt: null, retryAt: null, ownerId: null, @@ -347,12 +406,12 @@ export class TaskManagerRunner implements TaskRunner { } private shouldTryToScheduleRetry(): boolean { - if (this.instance.schedule) { + if (this.instance.task.schedule) { return true; } - const maxAttempts = this.definition.maxAttempts || this.bufferedTaskStore.maxAttempts; - return this.instance.attempts < maxAttempts; + const maxAttempts = this.definition.maxAttempts || this.defaultMaxAttempts; + return this.instance.task.attempts < maxAttempts; } private rescheduleFailedRun = ( @@ -361,7 +420,7 @@ export class TaskManagerRunner implements TaskRunner { const { state, error } = failureResult; if (this.shouldTryToScheduleRetry() && !isUnrecoverableError(error)) { // if we're retrying, keep the number of attempts - const { schedule, attempts } = this.instance; + const { schedule, attempts } = this.instance.task; const reschedule = failureResult.runAt ? { runAt: failureResult.runAt } @@ -399,7 +458,7 @@ export class TaskManagerRunner implements TaskRunner { // if retrying is possible (new runAt) or this is an recurring task - reschedule mapOk( ({ runAt, schedule: reschedule, state, attempts = 0 }: Partial) => { - const { startedAt, schedule } = this.instance; + const { startedAt, schedule } = this.instance.task; return asOk({ runAt: runAt || intervalFromDate(startedAt!, reschedule?.interval ?? schedule?.interval)!, @@ -413,16 +472,18 @@ export class TaskManagerRunner implements TaskRunner { unwrap )(result); - await this.bufferedTaskStore.update( - defaults( - { - ...fieldUpdates, - // reset fields that track the lifecycle of the concluded `task run` - startedAt: null, - retryAt: null, - ownerId: null, - }, - this.instance + this.instance = asRan( + await this.bufferedTaskStore.update( + defaults( + { + ...fieldUpdates, + // reset fields that track the lifecycle of the concluded `task run` + startedAt: null, + retryAt: null, + ownerId: null, + }, + this.instance.task + ) ) ); @@ -436,7 +497,8 @@ export class TaskManagerRunner implements TaskRunner { private async processResultWhenDone(): Promise { // not a recurring task: clean up by removing the task instance from store try { - await this.bufferedTaskStore.remove(this.instance.id); + await this.bufferedTaskStore.remove(this.id); + this.instance = asRan(this.instance.task); } catch (err) { if (err.statusCode === 404) { this.logger.warn(`Task cleanup of ${this} failed in processing. Was remove called twice?`); @@ -451,7 +513,7 @@ export class TaskManagerRunner implements TaskRunner { result: Result, taskTiming: TaskTiming ): Promise> { - const task = this.instance; + const { task } = this.instance; await eitherAsync( result, async ({ runAt, schedule }: SuccessfulRunResult) => { @@ -528,3 +590,38 @@ function performanceStopMarkingTaskAsRunning() { 'markTaskAsRunning_stop' ); } + +// A type that extracts the Instance type out of TaskRunningStage +// This helps us to better communicate to the developer what the expected "stage" +// in a specific place in the code might be +type InstanceOf = T extends TaskRunning ? I : never; + +function isPending(taskRunning: TaskRunningInstance): taskRunning is PendingTask { + return taskRunning.stage === TaskRunningStage.PENDING; +} +function asPending(task: InstanceOf): PendingTask { + return { + timestamp: new Date(), + stage: TaskRunningStage.PENDING, + task, + }; +} +function isReadyToRun(taskRunning: TaskRunningInstance): taskRunning is ReadyToRunTask { + return taskRunning.stage === TaskRunningStage.READY_TO_RUN; +} +function asReadyToRun( + task: InstanceOf +): ReadyToRunTask { + return { + timestamp: new Date(), + stage: TaskRunningStage.READY_TO_RUN, + task, + }; +} +function asRan(task: InstanceOf): RanTask { + return { + timestamp: new Date(), + stage: TaskRunningStage.RAN, + task, + }; +} diff --git a/x-pack/plugins/task_manager/server/task_scheduling.test.ts b/x-pack/plugins/task_manager/server/task_scheduling.test.ts index e495d416d5ab86..b142f2091291ed 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.test.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.test.ts @@ -7,13 +7,14 @@ import _ from 'lodash'; import { Subject } from 'rxjs'; -import { none } from 'fp-ts/lib/Option'; +import { none, some } from 'fp-ts/lib/Option'; import { asTaskMarkRunningEvent, asTaskRunEvent, asTaskClaimEvent, asTaskRunRequestEvent, + TaskClaimErrorType, } from './task_events'; import { TaskLifecycleEvent } from './polling_lifecycle'; import { taskPollingLifecycleMock } from './polling_lifecycle.mock'; @@ -24,17 +25,28 @@ import { createInitialMiddleware } from './lib/middleware'; import { taskStoreMock } from './task_store.mock'; import { TaskRunResult } from './task_running'; import { mockLogger } from './test_utils'; +import { TaskTypeDictionary } from './task_type_dictionary'; describe('TaskScheduling', () => { const mockTaskStore = taskStoreMock.create({}); const mockTaskManager = taskPollingLifecycleMock.create({}); + const definitions = new TaskTypeDictionary(mockLogger()); const taskSchedulingOpts = { taskStore: mockTaskStore, taskPollingLifecycle: mockTaskManager, logger: mockLogger(), middleware: createInitialMiddleware(), + definitions, }; + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + beforeEach(() => { jest.resetAllMocks(); }); @@ -114,7 +126,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); events$.next(asTaskRunEvent(id, asOk({ task, result: TaskRunResult.Success }))); return expect(result).resolves.toEqual({ id }); @@ -131,7 +143,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); events$.next(asTaskClaimEvent(id, asOk(task))); events$.next(asTaskMarkRunningEvent(id, asOk(task))); events$.next( @@ -161,7 +173,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); events$.next(asTaskClaimEvent(id, asOk(task))); events$.next(asTaskMarkRunningEvent(id, asErr(new Error('some thing gone wrong')))); @@ -183,7 +195,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toEqual( new Error(`Failed to run task "${id}" as it does not exist`) @@ -192,6 +209,34 @@ describe('TaskScheduling', () => { expect(mockTaskStore.getLifecycle).toHaveBeenCalledWith(id); }); + test('when a task claim due to insufficient capacity we return an explciit message', async () => { + const events$ = new Subject(); + const id = '01ddff11-e88a-4d13-bc4e-256164e755e2'; + + mockTaskStore.getLifecycle.mockResolvedValue(TaskLifecycleResult.NotFound); + + const taskScheduling = new TaskScheduling({ + ...taskSchedulingOpts, + taskPollingLifecycle: taskPollingLifecycleMock.create({ events$ }), + }); + + const result = taskScheduling.runNow(id); + + const task = mockTask({ id, taskType: 'foo' }); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: some(task), errorType: TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY }) + ) + ); + + await expect(result).rejects.toEqual( + new Error( + `Failed to run task "${id}" as we would exceed the max concurrency of "${task.taskType}" which is 2. Rescheduled the task to ensure it is picked up as soon as possible.` + ) + ); + }); + test('when a task claim fails we ensure the task isnt already claimed', async () => { const events$ = new Subject(); const id = '01ddff11-e88a-4d13-bc4e-256164e755e2'; @@ -205,7 +250,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toEqual( new Error(`Failed to run task "${id}" as it is currently running`) @@ -227,7 +277,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toEqual( new Error(`Failed to run task "${id}" as it is currently running`) @@ -270,7 +325,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toMatchInlineSnapshot( `[Error: Failed to run task "01ddff11-e88a-4d13-bc4e-256164e755e2" for unknown reason (Current Task Lifecycle is "idle")]` @@ -292,7 +352,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toMatchInlineSnapshot( `[Error: Failed to run task "01ddff11-e88a-4d13-bc4e-256164e755e2" for unknown reason (Current Task Lifecycle is "failed")]` @@ -313,7 +378,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); const otherTask = { id: differentTask } as ConcreteTaskInstance; events$.next(asTaskClaimEvent(id, asOk(task))); events$.next(asTaskClaimEvent(differentTask, asOk(otherTask))); @@ -338,3 +403,23 @@ describe('TaskScheduling', () => { }); }); }); + +function mockTask(overrides: Partial = {}): ConcreteTaskInstance { + return { + id: 'claimed-by-id', + runAt: new Date(), + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: '', + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + ...overrides, + }; +} diff --git a/x-pack/plugins/task_manager/server/task_scheduling.ts b/x-pack/plugins/task_manager/server/task_scheduling.ts index 8ccedb85c560df..29e83ec911b795 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.ts @@ -8,7 +8,7 @@ import { filter } from 'rxjs/operators'; import { pipe } from 'fp-ts/lib/pipeable'; -import { Option, map as mapOptional, getOrElse } from 'fp-ts/lib/Option'; +import { Option, map as mapOptional, getOrElse, isSome } from 'fp-ts/lib/Option'; import { Logger } from '../../../../src/core/server'; import { asOk, either, map, mapErr, promiseResult } from './lib/result_type'; @@ -20,6 +20,8 @@ import { ErroredTask, OkResultOf, ErrResultOf, + ClaimTaskErr, + TaskClaimErrorType, } from './task_events'; import { Middleware } from './lib/middleware'; import { @@ -33,6 +35,7 @@ import { import { TaskStore } from './task_store'; import { ensureDeprecatedFieldsAreCorrected } from './lib/correct_deprecated_fields'; import { TaskLifecycleEvent, TaskPollingLifecycle } from './polling_lifecycle'; +import { TaskTypeDictionary } from './task_type_dictionary'; const VERSION_CONFLICT_STATUS = 409; @@ -41,6 +44,7 @@ export interface TaskSchedulingOpts { taskStore: TaskStore; taskPollingLifecycle: TaskPollingLifecycle; middleware: Middleware; + definitions: TaskTypeDictionary; } interface RunNowResult { @@ -52,6 +56,7 @@ export class TaskScheduling { private taskPollingLifecycle: TaskPollingLifecycle; private logger: Logger; private middleware: Middleware; + private definitions: TaskTypeDictionary; /** * Initializes the task manager, preventing any further addition of middleware, @@ -63,6 +68,7 @@ export class TaskScheduling { this.middleware = opts.middleware; this.taskPollingLifecycle = opts.taskPollingLifecycle; this.store = opts.taskStore; + this.definitions = opts.definitions; } /** @@ -122,10 +128,27 @@ export class TaskScheduling { .pipe(filter(({ id }: TaskLifecycleEvent) => id === taskId)) .subscribe((taskEvent: TaskLifecycleEvent) => { if (isTaskClaimEvent(taskEvent)) { - mapErr(async (error: Option) => { + mapErr(async (error: ClaimTaskErr) => { // reject if any error event takes place for the requested task subscription.unsubscribe(); - return reject(await this.identifyTaskFailureReason(taskId, error)); + if ( + isSome(error.task) && + error.errorType === TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY + ) { + const task = error.task.value; + const definition = this.definitions.get(task.taskType); + return reject( + new Error( + `Failed to run task "${taskId}" as we would exceed the max concurrency of "${ + definition?.title ?? task.taskType + }" which is ${ + definition?.maxConcurrency + }. Rescheduled the task to ensure it is picked up as soon as possible.` + ) + ); + } else { + return reject(await this.identifyTaskFailureReason(taskId, error.task)); + } }, taskEvent.event); } else { either, ErrResultOf>( diff --git a/x-pack/plugins/task_manager/server/task_store.mock.ts b/x-pack/plugins/task_manager/server/task_store.mock.ts index d4f863af6fe3b1..38d570f96220bc 100644 --- a/x-pack/plugins/task_manager/server/task_store.mock.ts +++ b/x-pack/plugins/task_manager/server/task_store.mock.ts @@ -5,38 +5,27 @@ * 2.0. */ -import { Observable, Subject } from 'rxjs'; -import { TaskClaim } from './task_events'; - import { TaskStore } from './task_store'; interface TaskStoreOptions { - maxAttempts?: number; index?: string; taskManagerId?: string; - events?: Observable; } export const taskStoreMock = { - create({ - maxAttempts = 0, - index = '', - taskManagerId = '', - events = new Subject(), - }: TaskStoreOptions) { + create({ index = '', taskManagerId = '' }: TaskStoreOptions = {}) { const mocked = ({ + convertToSavedObjectIds: jest.fn(), update: jest.fn(), remove: jest.fn(), schedule: jest.fn(), - claimAvailableTasks: jest.fn(), bulkUpdate: jest.fn(), get: jest.fn(), getLifecycle: jest.fn(), fetch: jest.fn(), aggregate: jest.fn(), - maxAttempts, + updateByQuery: jest.fn(), index, taskManagerId, - events, } as unknown) as jest.Mocked; return mocked; }, diff --git a/x-pack/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts index dbf13a5f272810..25ee8cb0e23745 100644 --- a/x-pack/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -6,19 +6,16 @@ */ import _ from 'lodash'; -import uuid from 'uuid'; -import { filter, take, first } from 'rxjs/operators'; -import { Option, some, none } from 'fp-ts/lib/Option'; +import { first } from 'rxjs/operators'; import { TaskInstance, TaskStatus, TaskLifecycleResult, SerializedConcreteTaskInstance, - ConcreteTaskInstance, } from './task'; import { elasticsearchServiceMock } from '../../../../src/core/server/mocks'; -import { StoreOpts, OwnershipClaimingOpts, TaskStore, SearchOpts } from './task_store'; +import { TaskStore, SearchOpts } from './task_store'; import { savedObjectsRepositoryMock } from 'src/core/server/mocks'; import { SavedObjectsSerializer, @@ -26,12 +23,8 @@ import { SavedObjectAttributes, SavedObjectsErrorHelpers, } from 'src/core/server'; -import { asTaskClaimEvent, TaskEvent } from './task_events'; -import { asOk, asErr } from './lib/result_type'; import { TaskTypeDictionary } from './task_type_dictionary'; import { RequestEvent } from '@elastic/elasticsearch/lib/Transport'; -import { Search, UpdateByQuery } from '@elastic/elasticsearch/api/requestParams'; -import { BoolClauseWithAnyCondition, TermFilter } from './queries/query_clauses'; import { mockLogger } from './test_utils'; const savedObjectsClient = savedObjectsRepositoryMock.create(); @@ -76,7 +69,6 @@ describe('TaskStore', () => { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -209,7 +201,6 @@ describe('TaskStore', () => { taskManagerId: '', serializer, esClient, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -265,809 +256,6 @@ describe('TaskStore', () => { }); }); - describe('claimAvailableTasks', () => { - async function testClaimAvailableTasks({ - opts = {}, - hits = generateFakeTasks(1), - claimingOpts, - versionConflicts = 2, - }: { - opts: Partial; - hits?: unknown[]; - claimingOpts: OwnershipClaimingOpts; - versionConflicts?: number; - }) { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.search.mockResolvedValue(asApiResponse({ hits: { hits } })); - esClient.updateByQuery.mockResolvedValue( - asApiResponse({ - total: hits.length + versionConflicts, - updated: hits.length, - version_conflicts: versionConflicts, - }) - ); - - const store = new TaskStore({ - esClient, - maxAttempts: 2, - definitions: taskDefinitions, - serializer, - savedObjectsRepository: savedObjectsClient, - taskManagerId: '', - index: '', - ...opts, - }); - - const result = await store.claimAvailableTasks(claimingOpts); - - expect(esClient.updateByQuery.mock.calls[0][0]).toMatchObject({ - max_docs: claimingOpts.size, - }); - expect(esClient.search.mock.calls[0][0]).toMatchObject({ body: { size: claimingOpts.size } }); - return { - result, - args: { - search: esClient.search.mock.calls[0][0]! as Search<{ - query: BoolClauseWithAnyCondition; - size: number; - sort: string | string[]; - }>, - updateByQuery: esClient.updateByQuery.mock.calls[0][0]! as UpdateByQuery<{ - query: BoolClauseWithAnyCondition; - size: number; - sort: string | string[]; - script: object; - }>, - }, - }; - } - - test('it returns normally with no tasks when the index does not exist.', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.updateByQuery.mockResolvedValue( - asApiResponse({ - total: 0, - updated: 0, - }) - ); - const store = new TaskStore({ - index: 'tasky', - taskManagerId: '', - serializer, - esClient, - definitions: taskDefinitions, - maxAttempts: 2, - savedObjectsRepository: savedObjectsClient, - }); - const { docs } = await store.claimAvailableTasks({ - claimOwnershipUntil: new Date(), - size: 10, - }); - expect(esClient.updateByQuery.mock.calls[0][0]).toMatchObject({ - ignore_unavailable: true, - max_docs: 10, - }); - expect(docs.length).toBe(0); - }); - - test('it filters claimed tasks down by supported types, maxAttempts, status, and runAt', async () => { - const maxAttempts = _.random(2, 43); - const customMaxAttempts = _.random(44, 100); - - const definitions = new TaskTypeDictionary(mockLogger()); - definitions.registerTaskDefinitions({ - foo: { - title: 'foo', - createTaskRunner: jest.fn(), - }, - bar: { - title: 'bar', - maxAttempts: customMaxAttempts, - createTaskRunner: jest.fn(), - }, - }); - - const { - args: { - updateByQuery: { body: { query, sort } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - maxAttempts, - definitions, - }, - claimingOpts: { claimOwnershipUntil: new Date(), size: 10 }, - }); - expect(query).toMatchObject({ - bool: { - must: [ - { term: { type: 'task' } }, - { - bool: { - must: [ - { - bool: { - must: [ - { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'task.status': 'idle' } }, - { range: { 'task.runAt': { lte: 'now' } } }, - ], - }, - }, - { - bool: { - must: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }, - { range: { 'task.retryAt': { lte: 'now' } } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - ], - filter: [ - { - bool: { - must_not: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - must: { range: { 'task.retryAt': { gt: 'now' } } }, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }); - expect(sort).toMatchObject([ - { - _script: { - type: 'number', - order: 'asc', - script: { - lang: 'painless', - source: ` -if (doc['task.retryAt'].size()!=0) { - return doc['task.retryAt'].value.toInstant().toEpochMilli(); -} -if (doc['task.runAt'].size()!=0) { - return doc['task.runAt'].value.toInstant().toEpochMilli(); -} - `, - }, - }, - }, - ]); - }); - - test('it supports claiming specific tasks by id', async () => { - const maxAttempts = _.random(2, 43); - const customMaxAttempts = _.random(44, 100); - const definitions = new TaskTypeDictionary(mockLogger()); - const taskManagerId = uuid.v1(); - const fieldUpdates = { - ownerId: taskManagerId, - retryAt: new Date(Date.now()), - }; - definitions.registerTaskDefinitions({ - foo: { - title: 'foo', - createTaskRunner: jest.fn(), - }, - bar: { - title: 'bar', - maxAttempts: customMaxAttempts, - createTaskRunner: jest.fn(), - }, - }); - const { - args: { - updateByQuery: { body: { query, script, sort } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - maxAttempts, - definitions, - }, - claimingOpts: { - claimOwnershipUntil: new Date(), - size: 10, - claimTasksById: [ - '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', - 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', - ], - }, - }); - - expect(query).toMatchObject({ - bool: { - must: [ - { term: { type: 'task' } }, - { - bool: { - must: [ - { - pinned: { - ids: [ - 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', - 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', - ], - organic: { - bool: { - must: [ - { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'task.status': 'idle' } }, - { range: { 'task.runAt': { lte: 'now' } } }, - ], - }, - }, - { - bool: { - must: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }, - { range: { 'task.retryAt': { lte: 'now' } } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, - ], - filter: [ - { - bool: { - must_not: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - must: { range: { 'task.retryAt': { gt: 'now' } } }, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }); - - expect(script).toMatchObject({ - source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { - ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) - .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) - .join(' ')} - } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, - lang: 'painless', - params: { - fieldUpdates, - claimTasksById: [ - 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', - 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', - ], - registeredTaskTypes: ['foo', 'bar'], - taskMaxAttempts: { - bar: customMaxAttempts, - foo: maxAttempts, - }, - }, - }); - - expect(sort).toMatchObject([ - '_score', - { - _script: { - type: 'number', - order: 'asc', - script: { - lang: 'painless', - source: ` -if (doc['task.retryAt'].size()!=0) { - return doc['task.retryAt'].value.toInstant().toEpochMilli(); -} -if (doc['task.runAt'].size()!=0) { - return doc['task.runAt'].value.toInstant().toEpochMilli(); -} - `, - }, - }, - }, - ]); - }); - - test('it claims tasks by setting their ownerId, status and retryAt', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const fieldUpdates = { - ownerId: taskManagerId, - retryAt: claimOwnershipUntil, - }; - const { - args: { - updateByQuery: { body: { script } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - }); - expect(script).toMatchObject({ - source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { - ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) - .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) - .join(' ')} - } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, - lang: 'painless', - params: { - fieldUpdates, - claimTasksById: [], - registeredTaskTypes: ['report', 'dernstraight', 'yawn'], - taskMaxAttempts: { - dernstraight: 2, - report: 2, - yawn: 2, - }, - }, - }); - }); - - test('it filters out running tasks', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - // this is invalid as it doesn't have the `type` prefix - _id: 'bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const { - result: { docs }, - args: { - search: { body: { query } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - hits: tasks, - }); - - expect(query?.bool?.must).toContainEqual({ - bool: { - must: [ - { - term: { - 'task.ownerId': taskManagerId, - }, - }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }); - - expect(docs).toMatchObject([ - { - attempts: 0, - id: 'aaa', - schedule: undefined, - params: { hello: 'world' }, - runAt, - scope: ['reporting'], - state: { baby: 'Henhen' }, - status: 'claiming', - taskType: 'foo', - user: 'jimbo', - ownerId: taskManagerId, - }, - ]); - }); - - test('it filters out invalid tasks that arent SavedObjects', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'running', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const { - result: { docs } = {}, - args: { - search: { body: { query } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - hits: tasks, - }); - - expect(query?.bool?.must).toContainEqual({ - bool: { - must: [ - { - term: { - 'task.ownerId': taskManagerId, - }, - }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }); - - expect(docs).toMatchObject([ - { - attempts: 0, - id: 'aaa', - schedule: undefined, - params: { hello: 'world' }, - runAt, - scope: ['reporting'], - state: { baby: 'Henhen' }, - status: 'claiming', - taskType: 'foo', - user: 'jimbo', - ownerId: taskManagerId, - }, - ]); - }); - - test('it returns task objects', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const { - result: { docs } = {}, - args: { - search: { body: { query } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - hits: tasks, - }); - - expect(query?.bool?.must).toContainEqual({ - bool: { - must: [ - { - term: { - 'task.ownerId': taskManagerId, - }, - }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }); - - expect(docs).toMatchObject([ - { - attempts: 0, - id: 'aaa', - schedule: undefined, - params: { hello: 'world' }, - runAt, - scope: ['reporting'], - state: { baby: 'Henhen' }, - status: 'claiming', - taskType: 'foo', - user: 'jimbo', - ownerId: taskManagerId, - }, - { - attempts: 2, - id: 'bbb', - schedule: { interval: '5m' }, - params: { shazm: 1 }, - runAt, - scope: ['reporting', 'ceo'], - state: { henry: 'The 8th' }, - status: 'claiming', - taskType: 'bar', - user: 'dabo', - ownerId: taskManagerId, - }, - ]); - }); - - test('it returns version_conflicts that do not include conflicts that were proceeded against', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const maxDocs = 10; - const { - result: { stats: { tasksUpdated, tasksConflicted, tasksClaimed } = {} } = {}, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: maxDocs, - }, - hits: tasks, - // assume there were 20 version conflists, but thanks to `conflicts="proceed"` - // we proceeded to claim tasks - versionConflicts: 20, - }); - - expect(tasksUpdated).toEqual(2); - // ensure we only count conflicts that *may* have counted against max_docs, no more than that - expect(tasksConflicted).toEqual(10 - tasksUpdated!); - expect(tasksClaimed).toEqual(2); - }); - - test('pushes error from saved objects client to errors$', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - const store = new TaskStore({ - index: 'tasky', - taskManagerId: '', - serializer, - esClient, - definitions: taskDefinitions, - maxAttempts: 2, - savedObjectsRepository: savedObjectsClient, - }); - - const firstErrorPromise = store.errors$.pipe(first()).toPromise(); - esClient.updateByQuery.mockRejectedValue(new Error('Failure')); - await expect( - store.claimAvailableTasks({ - claimOwnershipUntil: new Date(), - size: 10, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Failure"`); - expect(await firstErrorPromise).toMatchInlineSnapshot(`[Error: Failure]`); - }); - }); - describe('update', () => { let store: TaskStore; let esClient: ReturnType['asInternalUser']; @@ -1079,7 +267,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1179,7 +366,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1219,7 +405,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1251,7 +436,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1335,7 +519,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1355,7 +538,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1373,7 +555,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1381,283 +562,8 @@ if (doc['task.runAt'].size()!=0) { return expect(store.getLifecycle(randomId())).rejects.toThrow('Bad Request'); }); }); - - describe('task events', () => { - function generateTasks() { - const taskManagerId = uuid.v1(); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:claimed-by-id', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:claimed-by-schedule', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - { - _id: 'task:already-running', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'running', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - - return { taskManagerId, runAt, tasks }; - } - - function instantiateStoreWithMockedApiResponses() { - const { taskManagerId, runAt, tasks } = generateTasks(); - - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.search.mockResolvedValue(asApiResponse({ hits: { hits: tasks } })); - esClient.updateByQuery.mockResolvedValue( - asApiResponse({ - total: tasks.length, - updated: tasks.length, - }) - ); - - const store = new TaskStore({ - esClient, - maxAttempts: 2, - definitions: taskDefinitions, - serializer, - savedObjectsRepository: savedObjectsClient, - taskManagerId, - index: '', - }); - - return { taskManagerId, runAt, store }; - } - - test('emits an event when a task is succesfully claimed by id', async () => { - const { taskManagerId, runAt, store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'claimed-by-id' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['claimed-by-id'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject( - asTaskClaimEvent( - 'claimed-by-id', - asOk({ - id: 'claimed-by-id', - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming' as TaskStatus, - params: { hello: 'world' }, - state: { baby: 'Henhen' }, - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }) - ) - ); - }); - - test('emits an event when a task is succesfully by scheduling', async () => { - const { taskManagerId, runAt, store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'claimed-by-schedule' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['claimed-by-id'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject( - asTaskClaimEvent( - 'claimed-by-schedule', - asOk({ - id: 'claimed-by-schedule', - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming' as TaskStatus, - params: { shazm: 1 }, - state: { henry: 'The 8th' }, - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }) - ) - ); - }); - - test('emits an event when the store fails to claim a required task by id', async () => { - const { taskManagerId, runAt, store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'already-running' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['already-running'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject( - asTaskClaimEvent( - 'already-running', - asErr( - some({ - id: 'already-running', - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'running' as TaskStatus, - params: { shazm: 1 }, - state: { henry: 'The 8th' }, - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }) - ) - ) - ); - }); - - test('emits an event when the store fails to find a task which was required by id', async () => { - const { store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'unknown-task' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['unknown-task'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject(asTaskClaimEvent('unknown-task', asErr(none))); - }); - }); }); -function generateFakeTasks(count: number = 1) { - return _.times(count, (index) => ({ - _id: `task:id-${index}`, - _source: { - type: 'task', - task: {}, - }, - _seq_no: _.random(1, 5), - _primary_term: _.random(1, 5), - sort: ['a', _.random(1, 5)], - })); -} - const asApiResponse = (body: T): RequestEvent => ({ body, diff --git a/x-pack/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts index b72f1826b813bf..0b54f2779065f6 100644 --- a/x-pack/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -8,13 +8,9 @@ /* * This module contains helpers for managing the task manager storage layer. */ -import apm from 'elastic-apm-node'; -import { Subject, Observable } from 'rxjs'; -import { omit, difference, partition, map, defaults } from 'lodash'; - -import { some, none } from 'fp-ts/lib/Option'; - -import { SearchResponse, UpdateDocumentByQueryResponse } from 'elasticsearch'; +import { Subject } from 'rxjs'; +import { omit, defaults } from 'lodash'; +import { ReindexResponseBase, SearchResponse, UpdateDocumentByQueryResponse } from 'elasticsearch'; import { SavedObject, SavedObjectsSerializer, @@ -32,38 +28,15 @@ import { TaskLifecycle, TaskLifecycleResult, SerializedConcreteTaskInstance, - TaskStatus, } from './task'; -import { TaskClaim, asTaskClaimEvent } from './task_events'; - -import { - asUpdateByQuery, - shouldBeOneOf, - mustBeAllOf, - filterDownBy, - asPinnedQuery, - matchesClauses, - SortOptions, -} from './queries/query_clauses'; - -import { - updateFieldsAndMarkAsFailed, - IdleTaskWithExpiredRunAt, - InactiveTasks, - RunningOrClaimingTaskWithExpiredRetryAt, - SortByRunAtAndRetryAt, - tasksClaimedByOwner, -} from './queries/mark_available_tasks_as_claimed'; import { TaskTypeDictionary } from './task_type_dictionary'; - import { ESSearchResponse, ESSearchBody } from '../../../typings/elasticsearch'; export interface StoreOpts { esClient: ElasticsearchClient; index: string; taskManagerId: string; - maxAttempts: number; definitions: TaskTypeDictionary; savedObjectsRepository: ISavedObjectsRepository; serializer: SavedObjectsSerializer; @@ -88,25 +61,10 @@ export interface UpdateByQueryOpts extends SearchOpts { max_docs?: number; } -export interface OwnershipClaimingOpts { - claimOwnershipUntil: Date; - claimTasksById?: string[]; - size: number; -} - export interface FetchResult { docs: ConcreteTaskInstance[]; } -export interface ClaimOwnershipResult { - stats: { - tasksUpdated: number; - tasksConflicted: number; - tasksClaimed: number; - }; - docs: ConcreteTaskInstance[]; -} - export type BulkUpdateResult = Result< ConcreteTaskInstance, { entity: ConcreteTaskInstance; error: Error } @@ -123,7 +81,6 @@ export interface UpdateByQueryResult { * interface into the index. */ export class TaskStore { - public readonly maxAttempts: number; public readonly index: string; public readonly taskManagerId: string; public readonly errors$ = new Subject(); @@ -132,14 +89,12 @@ export class TaskStore { private definitions: TaskTypeDictionary; private savedObjectsRepository: ISavedObjectsRepository; private serializer: SavedObjectsSerializer; - private events$: Subject; /** * Constructs a new TaskStore. * @param {StoreOpts} opts * @prop {esClient} esClient - An elasticsearch client * @prop {string} index - The name of the task manager index - * @prop {number} maxAttempts - The maximum number of attempts before a task will be abandoned * @prop {TaskDefinition} definition - The definition of the task being run * @prop {serializer} - The saved object serializer * @prop {savedObjectsRepository} - An instance to the saved objects repository @@ -148,21 +103,22 @@ export class TaskStore { this.esClient = opts.esClient; this.index = opts.index; this.taskManagerId = opts.taskManagerId; - this.maxAttempts = opts.maxAttempts; this.definitions = opts.definitions; this.serializer = opts.serializer; this.savedObjectsRepository = opts.savedObjectsRepository; - this.events$ = new Subject(); } - public get events(): Observable { - return this.events$; + /** + * Convert ConcreteTaskInstance Ids to match their SavedObject format as serialized + * in Elasticsearch + * @param tasks - The task being scheduled. + */ + public convertToSavedObjectIds( + taskIds: Array + ): Array { + return taskIds.map((id) => this.serializer.generateRawId(undefined, 'task', id)); } - private emitEvents = (events: TaskClaim[]) => { - events.forEach((event) => this.events$.next(event)); - }; - /** * Schedules a task. * @@ -201,144 +157,6 @@ export class TaskStore { }); } - /** - * Claims available tasks from the index, which are ready to be run. - * - runAt is now or past - * - is not currently claimed by any instance of Kibana - * - has a type that is in our task definitions - * - * @param {OwnershipClaimingOpts} options - * @returns {Promise} - */ - public claimAvailableTasks = async ({ - claimOwnershipUntil, - claimTasksById = [], - size, - }: OwnershipClaimingOpts): Promise => { - const claimTasksByIdWithRawIds = claimTasksById.map((id) => - this.serializer.generateRawId(undefined, 'task', id) - ); - - const { - updated: tasksUpdated, - version_conflicts: tasksConflicted, - } = await this.markAvailableTasksAsClaimed(claimOwnershipUntil, claimTasksByIdWithRawIds, size); - - const docs = - tasksUpdated > 0 ? await this.sweepForClaimedTasks(claimTasksByIdWithRawIds, size) : []; - - const [documentsReturnedById, documentsClaimedBySchedule] = partition(docs, (doc) => - claimTasksById.includes(doc.id) - ); - - const [documentsClaimedById, documentsRequestedButNotClaimed] = partition( - documentsReturnedById, - // we filter the schduled tasks down by status is 'claiming' in the esearch, - // but we do not apply this limitation on tasks claimed by ID so that we can - // provide more detailed error messages when we fail to claim them - (doc) => doc.status === TaskStatus.Claiming - ); - - const documentsRequestedButNotReturned = difference( - claimTasksById, - map(documentsReturnedById, 'id') - ); - - this.emitEvents([ - ...documentsClaimedById.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), - ...documentsClaimedBySchedule.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), - ...documentsRequestedButNotClaimed.map((doc) => asTaskClaimEvent(doc.id, asErr(some(doc)))), - ...documentsRequestedButNotReturned.map((id) => asTaskClaimEvent(id, asErr(none))), - ]); - - return { - stats: { - tasksUpdated, - tasksConflicted, - tasksClaimed: documentsClaimedById.length + documentsClaimedBySchedule.length, - }, - docs: docs.filter((doc) => doc.status === TaskStatus.Claiming), - }; - }; - - private async markAvailableTasksAsClaimed( - claimOwnershipUntil: OwnershipClaimingOpts['claimOwnershipUntil'], - claimTasksById: OwnershipClaimingOpts['claimTasksById'], - size: OwnershipClaimingOpts['size'] - ): Promise { - const registeredTaskTypes = this.definitions.getAllTypes(); - const taskMaxAttempts = [...this.definitions].reduce((accumulator, [type, { maxAttempts }]) => { - return { ...accumulator, [type]: maxAttempts || this.maxAttempts }; - }, {}); - const queryForScheduledTasks = mustBeAllOf( - // Either a task with idle status and runAt <= now or - // status running or claiming with a retryAt <= now. - shouldBeOneOf(IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt) - ); - - // The documents should be sorted by runAt/retryAt, unless there are pinned - // tasks being queried, in which case we want to sort by score first, and then - // the runAt/retryAt. That way we'll get the pinned tasks first. Note that - // the score seems to favor newer documents rather than older documents, so - // if there are not pinned tasks being queried, we do NOT want to sort by score - // at all, just by runAt/retryAt. - const sort: SortOptions = [SortByRunAtAndRetryAt]; - if (claimTasksById && claimTasksById.length) { - sort.unshift('_score'); - } - - const apmTrans = apm.startTransaction(`taskManager markAvailableTasksAsClaimed`, 'taskManager'); - const result = await this.updateByQuery( - asUpdateByQuery({ - query: matchesClauses( - mustBeAllOf( - claimTasksById && claimTasksById.length - ? asPinnedQuery(claimTasksById, queryForScheduledTasks) - : queryForScheduledTasks - ), - filterDownBy(InactiveTasks) - ), - update: updateFieldsAndMarkAsFailed( - { - ownerId: this.taskManagerId, - retryAt: claimOwnershipUntil, - }, - claimTasksById || [], - registeredTaskTypes, - taskMaxAttempts - ), - sort, - }), - { - max_docs: size, - } - ); - - if (apmTrans) apmTrans.end(); - return result; - } - - /** - * Fetches tasks from the index, which are owned by the current Kibana instance - */ - private async sweepForClaimedTasks( - claimTasksById: OwnershipClaimingOpts['claimTasksById'], - size: OwnershipClaimingOpts['size'] - ): Promise { - const claimedTasksQuery = tasksClaimedByOwner(this.taskManagerId); - const { docs } = await this.search({ - query: - claimTasksById && claimTasksById.length - ? asPinnedQuery(claimTasksById, claimedTasksQuery) - : claimedTasksQuery, - size, - sort: SortByRunAtAndRetryAt, - seq_no_primary_term: true, - }); - - return docs; - } - /** * Updates the specified doc in the index, returning the doc * with its version up to date. @@ -527,7 +345,7 @@ export class TaskStore { return body; } - private async updateByQuery( + public async updateByQuery( opts: UpdateByQuerySearchOpts = {}, // eslint-disable-next-line @typescript-eslint/naming-convention { max_docs: max_docs }: UpdateByQueryOpts = {} @@ -549,17 +367,11 @@ export class TaskStore { }, }); - /** - * When we run updateByQuery with conflicts='proceed', it's possible for the `version_conflicts` - * to count against the specified `max_docs`, as per https://github.com/elastic/elasticsearch/issues/63671 - * In order to correct for that happening, we only count `version_conflicts` if we haven't updated as - * many docs as we could have. - * This is still no more than an estimation, as there might have been less docuemnt to update that the - * `max_docs`, but we bias in favour of over zealous `version_conflicts` as that's the best indicator we - * have for an unhealthy cluster distribution of Task Manager polling intervals - */ - const conflictsCorrectedForContinuation = - max_docs && version_conflicts + updated > max_docs ? max_docs - updated : version_conflicts; + const conflictsCorrectedForContinuation = correctVersionConflictsForContinuation( + updated, + version_conflicts, + max_docs + ); return { total, @@ -572,6 +384,22 @@ export class TaskStore { } } } +/** + * When we run updateByQuery with conflicts='proceed', it's possible for the `version_conflicts` + * to count against the specified `max_docs`, as per https://github.com/elastic/elasticsearch/issues/63671 + * In order to correct for that happening, we only count `version_conflicts` if we haven't updated as + * many docs as we could have. + * This is still no more than an estimation, as there might have been less docuemnt to update that the + * `max_docs`, but we bias in favour of over zealous `version_conflicts` as that's the best indicator we + * have for an unhealthy cluster distribution of Task Manager polling intervals + */ +export function correctVersionConflictsForContinuation( + updated: ReindexResponseBase['updated'], + versionConflicts: ReindexResponseBase['version_conflicts'], + maxDocs?: number +) { + return maxDocs && versionConflicts + updated > maxDocs ? maxDocs - updated : versionConflicts; +} function taskInstanceToAttributes(doc: TaskInstance): SerializedConcreteTaskInstance { return { diff --git a/x-pack/plugins/task_manager/server/task_type_dictionary.ts b/x-pack/plugins/task_manager/server/task_type_dictionary.ts index 4230eb9ce4b737..63a0548d79d322 100644 --- a/x-pack/plugins/task_manager/server/task_type_dictionary.ts +++ b/x-pack/plugins/task_manager/server/task_type_dictionary.ts @@ -28,6 +28,10 @@ export class TaskTypeDictionary { return [...this.definitions.keys()]; } + public getAllDefinitions() { + return [...this.definitions.values()]; + } + public has(type: string) { return this.definitions.has(type); } diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts index 2878d7d5f8220b..57beb40b164592 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts @@ -218,10 +218,9 @@ export function initRoutes( await ensureIndexIsRefreshed(); const taskManager = await taskManagerStart; return res.ok({ body: await taskManager.get(req.params.taskId) }); - } catch (err) { - return res.ok({ body: err }); + } catch ({ isBoom, output, message }) { + return res.ok({ body: isBoom ? output.payload : { message } }); } - return res.ok({ body: {} }); } ); @@ -251,6 +250,7 @@ export function initRoutes( res: KibanaResponseFactory ): Promise> { try { + await ensureIndexIsRefreshed(); let tasksFound = 0; const taskManager = await taskManagerStart; do { @@ -261,8 +261,8 @@ export function initRoutes( await Promise.all(tasks.map((task) => taskManager.remove(task.id))); } while (tasksFound > 0); return res.ok({ body: 'OK' }); - } catch (err) { - return res.ok({ body: err }); + } catch ({ isBoom, output, message }) { + return res.ok({ body: isBoom ? output.payload : { message } }); } } ); diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts index 3aee35ed0bff3f..2031551410894a 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts @@ -105,6 +105,20 @@ export class SampleTaskManagerFixturePlugin // fail after the first failed run maxAttempts: 1, }, + sampleTaskWithSingleConcurrency: { + ...defaultSampleTaskConfig, + title: 'Sample Task With Single Concurrency', + maxConcurrency: 1, + timeout: '60s', + description: 'A sample task that can only have one concurrent instance.', + }, + sampleTaskWithLimitedConcurrency: { + ...defaultSampleTaskConfig, + title: 'Sample Task With Max Concurrency of 2', + maxConcurrency: 2, + timeout: '60s', + description: 'A sample task that can only have two concurrent instance.', + }, sampleRecurringTaskTimingOut: { title: 'Sample Recurring Task that Times Out', description: 'A sample task that times out each run.', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts index 231150a8148354..d99c1dac9a25e9 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts @@ -34,6 +34,7 @@ interface MonitoringStats { timestamp: string; value: { drift: Record; + drift_by_type: Record>; load: Record; execution: { duration: Record>; @@ -43,6 +44,7 @@ interface MonitoringStats { last_successful_poll: string; last_polling_delay: string; duration: Record; + claim_duration: Record; result_frequency_percent_as_number: Record; }; }; @@ -174,7 +176,8 @@ export default function ({ getService }: FtrProviderContext) { const { runtime: { - value: { drift, load, polling, execution }, + // eslint-disable-next-line @typescript-eslint/naming-convention + value: { drift, drift_by_type, load, polling, execution }, }, } = (await getHealth()).stats; @@ -192,11 +195,21 @@ export default function ({ getService }: FtrProviderContext) { expect(typeof polling.duration.p95).to.eql('number'); expect(typeof polling.duration.p99).to.eql('number'); + expect(typeof polling.claim_duration.p50).to.eql('number'); + expect(typeof polling.claim_duration.p90).to.eql('number'); + expect(typeof polling.claim_duration.p95).to.eql('number'); + expect(typeof polling.claim_duration.p99).to.eql('number'); + expect(typeof drift.p50).to.eql('number'); expect(typeof drift.p90).to.eql('number'); expect(typeof drift.p95).to.eql('number'); expect(typeof drift.p99).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p50).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p90).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p95).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p99).to.eql('number'); + expect(typeof load.p50).to.eql('number'); expect(typeof load.p90).to.eql('number'); expect(typeof load.p95).to.eql('number'); diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts index 353be5e872aed7..26333ecabd505d 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts @@ -51,7 +51,7 @@ type SerializedConcreteTaskInstance = Omit< }; export default function ({ getService }: FtrProviderContext) { - const es = getService('legacyEs'); + const es = getService('es'); const log = getService('log'); const retry = getService('retry'); const config = getService('config'); @@ -59,30 +59,46 @@ export default function ({ getService }: FtrProviderContext) { const supertest = supertestAsPromised(url.format(config.get('servers.kibana'))); describe('scheduling and running tasks', () => { - beforeEach( - async () => await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200) - ); + beforeEach(async () => { + // clean up before each test + return await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200); + }); beforeEach(async () => { const exists = await es.indices.exists({ index: testHistoryIndex }); - if (exists) { + if (exists.body) { await es.deleteByQuery({ index: testHistoryIndex, - q: 'type:task', refresh: true, + body: { query: { term: { type: 'task' } } }, }); } else { await es.indices.create({ index: testHistoryIndex, body: { mappings: { - properties: taskManagerIndexMapping, + properties: { + type: { + type: 'keyword', + }, + taskId: { + type: 'keyword', + }, + params: taskManagerIndexMapping.params, + state: taskManagerIndexMapping.state, + runAt: taskManagerIndexMapping.runAt, + }, }, }, }); } }); + after(async () => { + // clean up after last test + return await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200); + }); + function currentTasks(): Promise<{ docs: Array>; }> { @@ -98,7 +114,27 @@ export default function ({ getService }: FtrProviderContext) { return supertest .get(`/api/sample_tasks/task/${task}`) .send({ task }) - .expect(200) + .expect((response) => { + expect(response.status).to.eql(200); + expect(typeof JSON.parse(response.text).id).to.eql(`string`); + }) + .then((response) => response.body); + } + + function currentTaskError( + task: string + ): Promise<{ + statusCode: number; + error: string; + message: string; + }> { + return supertest + .get(`/api/sample_tasks/task/${task}`) + .send({ task }) + .expect(function (response) { + expect(response.status).to.eql(200); + expect(typeof JSON.parse(response.text).message).to.eql(`string`); + }) .then((response) => response.body); } @@ -106,13 +142,21 @@ export default function ({ getService }: FtrProviderContext) { return supertest.get(`/api/ensure_tasks_index_refreshed`).send({}).expect(200); } - function historyDocs(taskId?: string): Promise { + async function historyDocs(taskId?: string): Promise { return es .search({ index: testHistoryIndex, - q: taskId ? `taskId:${taskId}` : 'type:task', + body: { + query: { + term: { type: 'task' }, + }, + }, }) - .then((result: SearchResults) => result.hits.hits); + .then((result) => + ((result.body as unknown) as SearchResults).hits.hits.filter((task) => + taskId ? task._source?.taskId === taskId : true + ) + ); } function scheduleTask( @@ -123,7 +167,10 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ task }) .expect(200) - .then((response: { body: SerializedConcreteTaskInstance }) => response.body); + .then((response: { body: SerializedConcreteTaskInstance }) => { + log.debug(`Task Scheduled: ${response.body.id}`); + return response.body; + }); } function runTaskNow(task: { id: string }) { @@ -252,8 +299,7 @@ export default function ({ getService }: FtrProviderContext) { }); await retry.try(async () => { - const [scheduledTask] = (await currentTasks()).docs; - expect(scheduledTask.id).to.eql(task.id); + const scheduledTask = await currentTask(task.id); expect(scheduledTask.attempts).to.be.greaterThan(0); expect(Date.parse(scheduledTask.runAt)).to.be.greaterThan( Date.parse(task.runAt) + 5 * 60 * 1000 @@ -271,8 +317,7 @@ export default function ({ getService }: FtrProviderContext) { }); await retry.try(async () => { - const [scheduledTask] = (await currentTasks()).docs; - expect(scheduledTask.id).to.eql(task.id); + const scheduledTask = await currentTask(task.id); const retryAt = Date.parse(scheduledTask.retryAt!); expect(isNaN(retryAt)).to.be(false); @@ -296,7 +341,7 @@ export default function ({ getService }: FtrProviderContext) { await retry.try(async () => { expect((await historyDocs(originalTask.id)).length).to.eql(1); - const [task] = (await currentTasks<{ count: number }>()).docs; + const task = await currentTask<{ count: number }>(originalTask.id); expect(task.attempts).to.eql(0); expect(task.state.count).to.eql(count + 1); @@ -467,6 +512,134 @@ export default function ({ getService }: FtrProviderContext) { }); }); + it('should only run as many instances of a task as its maxConcurrency will allow', async () => { + // should run as there's only one and maxConcurrency on this TaskType is 1 + const firstWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + params: { + waitForEvent: 'releaseFirstWaveOfTasks', + }, + }); + + // should run as there's only two and maxConcurrency on this TaskType is 2 + const [firstLimitedConcurrency, secondLimitedConcurrency] = await Promise.all([ + scheduleTask({ + taskType: 'sampleTaskWithLimitedConcurrency', + params: { + waitForEvent: 'releaseFirstWaveOfTasks', + }, + }), + scheduleTask({ + taskType: 'sampleTaskWithLimitedConcurrency', + params: { + waitForEvent: 'releaseSecondWaveOfTasks', + }, + }), + ]); + + await retry.try(async () => { + expect((await historyDocs(firstWithSingleConcurrency.id)).length).to.eql(1); + expect((await historyDocs(firstLimitedConcurrency.id)).length).to.eql(1); + expect((await historyDocs(secondLimitedConcurrency.id)).length).to.eql(1); + }); + + // should not run as there one running and maxConcurrency on this TaskType is 1 + const secondWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + params: { + waitForEvent: 'releaseSecondWaveOfTasks', + }, + }); + + // should not run as there are two running and maxConcurrency on this TaskType is 2 + const thirdWithLimitedConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithLimitedConcurrency', + params: { + waitForEvent: 'releaseSecondWaveOfTasks', + }, + }); + + // schedule a task that should get picked up before the two blocked tasks + const taskWithUnlimitedConcurrency = await scheduleTask({ + taskType: 'sampleTask', + params: {}, + }); + + await retry.try(async () => { + expect((await historyDocs(taskWithUnlimitedConcurrency.id)).length).to.eql(1); + expect((await currentTask(secondWithSingleConcurrency.id)).status).to.eql('idle'); + expect((await currentTask(thirdWithLimitedConcurrency.id)).status).to.eql('idle'); + }); + + // release the running SingleConcurrency task and only one of the LimitedConcurrency tasks + await releaseTasksWaitingForEventToComplete('releaseFirstWaveOfTasks'); + + await retry.try(async () => { + // ensure the completed tasks were deleted + expect((await currentTaskError(firstWithSingleConcurrency.id)).message).to.eql( + `Saved object [task/${firstWithSingleConcurrency.id}] not found` + ); + expect((await currentTaskError(firstLimitedConcurrency.id)).message).to.eql( + `Saved object [task/${firstLimitedConcurrency.id}] not found` + ); + + // ensure blocked tasks is still running + expect((await currentTask(secondLimitedConcurrency.id)).status).to.eql('running'); + + // ensure the blocked tasks begin running + expect((await currentTask(secondWithSingleConcurrency.id)).status).to.eql('running'); + expect((await currentTask(thirdWithLimitedConcurrency.id)).status).to.eql('running'); + }); + + // release blocked task + await releaseTasksWaitingForEventToComplete('releaseSecondWaveOfTasks'); + }); + + it('should return a task run error result when RunNow is called at a time that would cause the task to exceed its maxConcurrency', async () => { + // should run as there's only one and maxConcurrency on this TaskType is 1 + const firstWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + // include a schedule so that the task isn't deleted after completion + schedule: { interval: `30m` }, + params: { + waitForEvent: 'releaseRunningTaskWithSingleConcurrency', + }, + }); + + // should not run as the first is running + const secondWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + params: { + waitForEvent: 'releaseRunningTaskWithSingleConcurrency', + }, + }); + + // run the first tasks once just so that we can be sure it runs in response to our + // runNow callm, rather than the initial execution + await retry.try(async () => { + expect((await historyDocs(firstWithSingleConcurrency.id)).length).to.eql(1); + }); + await releaseTasksWaitingForEventToComplete('releaseRunningTaskWithSingleConcurrency'); + + // wait for second task to stall + await retry.try(async () => { + expect((await historyDocs(secondWithSingleConcurrency.id)).length).to.eql(1); + }); + + // run the first task again using runNow - should fail due to concurrency concerns + const failedRunNowResult = await runTaskNow({ + id: firstWithSingleConcurrency.id, + }); + + expect(failedRunNowResult).to.eql({ + id: firstWithSingleConcurrency.id, + error: `Error: Failed to run task "${firstWithSingleConcurrency.id}" as we would exceed the max concurrency of "Sample Task With Single Concurrency" which is 1. Rescheduled the task to ensure it is picked up as soon as possible.`, + }); + + // release the second task + await releaseTasksWaitingForEventToComplete('releaseRunningTaskWithSingleConcurrency'); + }); + it('should return a task run error result when running a task now fails', async () => { const originalTask = await scheduleTask({ taskType: 'sampleTask',