From 6af5828b3d4b37af9ba6facb0bbeedc700a26bce Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 7 Apr 2021 23:06:05 -0400 Subject: [PATCH 01/22] [Security Solution][Detections]Fixes Rule Management Cypress Tests (#96505) (#96521) ## Summary Fixes two cypress tests: > Deleting prebuilt rules "before each" hook for "Does not allow to delete one rule when more than one is selected" https://github.com/elastic/kibana/issues/68607 This one is more of a drive around the pot-hole fix as we were waiting for the Alerts Table to load when we really didn't need to. Removed unnecessary check.

> Alerts rules, prebuilt rules Loads prebuilt rules https://github.com/elastic/kibana/issues/71300 This one was fixed with a `.pipe()` and `.should('not.be.visible')` to ensure the click was successful. Also removed unnecessary check on the Alerts Table loading that was present here as well too..

Co-authored-by: Garrett Spong --- .../integration/detection_rules/prebuilt_rules.spec.ts | 8 +------- .../cypress/tasks/alerts_detection_rules.ts | 5 ++++- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts index d290773d425e2..fb0a01bd1c7d3 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts @@ -14,11 +14,7 @@ import { SHOWING_RULES_TEXT, } from '../../screens/alerts_detection_rules'; -import { - goToManageAlertsDetectionRules, - waitForAlertsIndexToBeCreated, - waitForAlertsPanelToBeLoaded, -} from '../../tasks/alerts'; +import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; import { changeRowsPerPageTo300, deleteFirstRule, @@ -47,7 +43,6 @@ describe('Alerts rules, prebuilt rules', () => { const expectedElasticRulesBtnText = `Elastic rules (${expectedNumberOfRules})`; loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); - waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); goToManageAlertsDetectionRules(); waitForRulesTableToBeLoaded(); @@ -79,7 +74,6 @@ describe('Deleting prebuilt rules', () => { cleanKibana(); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); - waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); goToManageAlertsDetectionRules(); waitForRulesTableToBeLoaded(); diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 10644e046a68b..d66b839267ea0 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -191,7 +191,10 @@ export const resetAllRulesIdleModalTimeout = () => { export const changeRowsPerPageTo = (rowsCount: number) => { cy.get(PAGINATION_POPOVER_BTN).click({ force: true }); - cy.get(rowsPerPageSelector(rowsCount)).click(); + cy.get(rowsPerPageSelector(rowsCount)) + .pipe(($el) => $el.trigger('click')) + .should('not.be.visible'); + waitForRulesTableToBeRefreshed(); }; From 36d86ad29ad93121b3b1fda62cb403801debb180 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 8 Apr 2021 02:36:07 -0400 Subject: [PATCH 02/22] [Security Solution][Detections] Fixes Closing Alerts Cypress Test (#96523) (#96526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary As identified in https://github.com/elastic/kibana/pull/96505#issuecomment-815392671, this fixes the flakiness in the `Closing alerts` cypress test. Method used was to just delete the rule after the initial batch of alerts were generated. Alternatively we could add a function for disabling the rule (didn't see one in there), but the outcome is the same, no more alerts generated while the test is being performed. 🙂 > Passing locally, though upon further inspection, this test is definitely going to be flakey as it's checking counts on alerts as they move through different states and there are new alerts that keep coming in (hence the count mis-match in the above failure). Potential fixes would be to use an absolute daterange to after the first round of alerts were generated, or just stop generating alerts before performing the alert state changes. ##### Before:

##### After:

Co-authored-by: Garrett Spong --- .../cypress/integration/detection_alerts/closing.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts index e9d17a361d336..b7c0e1c6fcd6e 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts @@ -25,7 +25,7 @@ import { waitForAlerts, waitForAlertsIndexToBeCreated, } from '../../tasks/alerts'; -import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { createCustomRuleActivated, deleteCustomRule } from '../../tasks/api_calls/rules'; import { cleanKibana } from '../../tasks/common'; import { waitForAlertsToPopulate } from '../../tasks/create_new_rule'; import { loginAndWaitForPage } from '../../tasks/login'; @@ -42,6 +42,7 @@ describe('Closing alerts', () => { createCustomRuleActivated(newRule); refreshPage(); waitForAlertsToPopulate(); + deleteCustomRule(); }); it('Closes and opens alerts', () => { From 95992f0e984ed2dd9e2959cd5f1059b6e74c9a1f Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 8 Apr 2021 03:01:58 -0400 Subject: [PATCH 03/22] [Fleet] Install security_rule assets as saved objects (#95885) (#96527) * [Fleet] Install security_rule assets as saved objects * Add security-rule to update_assets.ts * Update UUIDs for security_rule asset * Change .type to match the saved object type not the asset type * Add saved object mapping for security-rule * Make SO non-hidden * Fix SO mapping for security-rule * Make security-rule a non-hidden asset Co-authored-by: Ross Wolf <31489089+rw-access@users.noreply.github.com> --- .../package_to_package_policy.test.ts | 1 + .../plugins/fleet/common/types/models/epm.ts | 2 + .../fleet/sections/epm/constants.tsx | 2 + .../services/epm/kibana/assets/install.ts | 2 + .../services/epm/packages/assets.test.ts | 2 +- .../rules/saved_object_mappings.ts | 24 +++++++++ .../security_solution/server/saved_objects.ts | 6 ++- .../apis/epm/install_remove_assets.ts | 10 ++++ .../apis/epm/update_assets.ts | 5 ++ .../security_rule/sample_security_rule.json | 50 +++++++++++++++++++ .../security_rule/sample_security_rule.json | 50 +++++++++++++++++++ 11 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/security_rule/sample_security_rule.json create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/security_rule/sample_security_rule.json diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts index a4cca4455a274..65b853ed5b38f 100644 --- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts @@ -31,6 +31,7 @@ describe('Fleet - packageToPackagePolicy', () => { map: [], lens: [], ml_module: [], + security_rule: [], }, elasticsearch: { ingest_pipeline: [], diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 80fabd51613ae..3bc0d97d64646 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -50,6 +50,7 @@ export enum KibanaAssetType { indexPattern = 'index_pattern', map = 'map', lens = 'lens', + securityRule = 'security_rule', mlModule = 'ml_module', } @@ -64,6 +65,7 @@ export enum KibanaSavedObjectType { map = 'map', lens = 'lens', mlModule = 'ml-module', + securityRule = 'security-rule', } export enum ElasticsearchAssetType { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/constants.tsx index ea19a330adfee..6ddff968bd3f3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/constants.tsx @@ -33,6 +33,7 @@ export const AssetTitleMap: Record = { map: 'Map', data_stream_ilm_policy: 'Data Stream ILM Policy', lens: 'Lens', + security_rule: 'Security Rule', ml_module: 'ML Module', }; @@ -48,6 +49,7 @@ export const AssetIcons: Record = { visualization: 'visualizeApp', map: 'emsApp', lens: 'lensApp', + security_rule: 'securityApp', ml_module: 'mlApp', }; diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index bfcc40e18fe80..0f2d7b6679bf9 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -38,6 +38,7 @@ const KibanaSavedObjectTypeMapping: Record { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/assets.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/assets.test.ts index 999cf878d07b7..c5b104696aaf4 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/assets.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/assets.test.ts @@ -43,7 +43,7 @@ const tests = [ name: 'coredns', version: '1.0.1', }, - // Non existant dataset + // Non existent dataset dataset: 'foo', filter: (path: string) => { return true; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/saved_object_mappings.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/saved_object_mappings.ts index 4ed53e39fa5eb..813e800f34ce2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/saved_object_mappings.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/saved_object_mappings.ts @@ -53,3 +53,27 @@ export const type: SavedObjectsType = { namespaceType: 'single', mappings: ruleStatusSavedObjectMappings, }; + +export const ruleAssetSavedObjectType = 'security-rule'; + +export const ruleAssetSavedObjectMappings: SavedObjectsType['mappings'] = { + dynamic: false, + properties: { + name: { + type: 'keyword', + }, + rule_id: { + type: 'keyword', + }, + version: { + type: 'long', + }, + }, +}; + +export const ruleAssetType: SavedObjectsType = { + name: ruleAssetSavedObjectType, + hidden: false, + namespaceType: 'agnostic', + mappings: ruleAssetSavedObjectMappings, +}; diff --git a/x-pack/plugins/security_solution/server/saved_objects.ts b/x-pack/plugins/security_solution/server/saved_objects.ts index d483bd25266af..42abb3dab2ac4 100644 --- a/x-pack/plugins/security_solution/server/saved_objects.ts +++ b/x-pack/plugins/security_solution/server/saved_objects.ts @@ -8,7 +8,10 @@ import { CoreSetup } from '../../../../src/core/server'; import { noteType, pinnedEventType, timelineType } from './lib/timeline/saved_object_mappings'; -import { type as ruleStatusType } from './lib/detection_engine/rules/saved_object_mappings'; +import { + type as ruleStatusType, + ruleAssetType, +} from './lib/detection_engine/rules/saved_object_mappings'; import { type as ruleActionsType } from './lib/detection_engine/rule_actions/saved_object_mappings'; import { type as signalsMigrationType } from './lib/detection_engine/migrations/saved_objects'; import { @@ -21,6 +24,7 @@ const types = [ pinnedEventType, ruleActionsType, ruleStatusType, + ruleAssetType, timelineType, exceptionsArtifactType, manifestType, diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index abc91a973e6b6..8e09e331bf867 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -399,6 +399,11 @@ const expectAssetsInstalled = ({ id: 'sample_ml_module', }); expect(resMlModule.id).equal('sample_ml_module'); + const resSecurityRule = await kibanaServer.savedObjects.get({ + type: 'security-rule', + id: 'sample_security_rule', + }); + expect(resSecurityRule.id).equal('sample_security_rule'); const resIndexPattern = await kibanaServer.savedObjects.get({ type: 'index-pattern', id: 'test-*', @@ -472,6 +477,10 @@ const expectAssetsInstalled = ({ id: 'sample_search', type: 'search', }, + { + id: 'sample_security_rule', + type: 'security-rule', + }, { id: 'sample_visualization', type: 'visualization', @@ -537,6 +546,7 @@ const expectAssetsInstalled = ({ { id: 'e21b59b5-eb76-5ab0-bef2-1c8e379e6197', type: 'epm-packages-assets' }, { id: '4c758d70-ecf1-56b3-b704-6d8374841b34', type: 'epm-packages-assets' }, { id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', type: 'epm-packages-assets' }, + { id: 'd8b175c3-0d42-5ec7-90c1-d1e4b307a4c2', type: 'epm-packages-assets' }, { id: '53c94591-aa33-591d-8200-cd524c2a0561', type: 'epm-packages-assets' }, { id: 'b658d2d4-752e-54b8-afc2-4c76155c1466', type: 'epm-packages-assets' }, ], diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 1a559ac5a5c75..9b55822311bd7 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -296,6 +296,10 @@ export default function (providerContext: FtrProviderContext) { id: 'sample_lens', type: 'lens', }, + { + id: 'sample_security_rule', + type: 'security-rule', + }, { id: 'sample_ml_module', type: 'ml-module', @@ -350,6 +354,7 @@ export default function (providerContext: FtrProviderContext) { { id: '7f4c5aca-b4f5-5f0a-95af-051da37513fc', type: 'epm-packages-assets' }, { id: '4281a436-45a8-54ab-9724-fda6849f789d', type: 'epm-packages-assets' }, { id: '2e56f08b-1d06-55ed-abee-4708e1ccf0aa', type: 'epm-packages-assets' }, + { id: '4035007b-9c33-5227-9803-2de8a17523b5', type: 'epm-packages-assets' }, { id: 'c7bf1a39-e057-58a0-afde-fb4b48751d8c', type: 'epm-packages-assets' }, { id: '8c665f28-a439-5f43-b5fd-8fda7b576735', type: 'epm-packages-assets' }, ], diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/security_rule/sample_security_rule.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/security_rule/sample_security_rule.json new file mode 100644 index 0000000000000..6bedde67b8923 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/security_rule/sample_security_rule.json @@ -0,0 +1,50 @@ +{ + "attributes": { + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious parent child process relationship with cmd.exe descending from svchost.exe", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*", + "logs-windows.*" + ], + "language": "kuery", + "license": "Elastic License v2", + "name": "Svchost spawning Cmd", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:svchost.exe and process.name:cmd.exe", + "risk_score": 21, + "rule_id": "sample_security_rule", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT\u0026CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "timestamp_override": "event.ingested", + "type": "query", + "version": 7 + }, + "id": "sample_security_rule", + "type": "security-rule" +} diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/security_rule/sample_security_rule.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/security_rule/sample_security_rule.json new file mode 100644 index 0000000000000..6bedde67b8923 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/security_rule/sample_security_rule.json @@ -0,0 +1,50 @@ +{ + "attributes": { + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious parent child process relationship with cmd.exe descending from svchost.exe", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*", + "logs-windows.*" + ], + "language": "kuery", + "license": "Elastic License v2", + "name": "Svchost spawning Cmd", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:svchost.exe and process.name:cmd.exe", + "risk_score": 21, + "rule_id": "sample_security_rule", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT\u0026CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "timestamp_override": "event.ingested", + "type": "query", + "version": 7 + }, + "id": "sample_security_rule", + "type": "security-rule" +} From 5180afc26312995db2cba7d373a725bafcf9ac20 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Thu, 8 Apr 2021 02:13:14 -0600 Subject: [PATCH 04/22] [Detection Rules] Resolves regression where Elastic Endgame rules would warn about unmapped timestamp override field (#96394) (#96528) related to https://github.com/elastic/detection-rules/pull/1082 ## Summary Endgame promotion rules in Kibana/7.12 are at version 5 and have timestamp_override defined (which should not be). These same rules are at version 4 in the detection-rules repo 7.12 branch and kibana/master and timestamp_override is not defined. These updates are targeted for 7.12.1 There most likely was an issue with the maze of backports and interlaced updates. To fix the rules, they need to be reconciled across: detection-rules 7.12 & main kibana 7.12.1 and master bump detection-rules/7.12 to v6 -> PR to kibana/master -> backport to 7.12.1 ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) # Conflicts: # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_adversary_behavior_detected.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_dumping_detected.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_dumping_prevented.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_manipulation_detected.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_manipulation_prevented.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_exploit_detected.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_exploit_prevented.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_malware_detected.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_malware_prevented.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_permission_theft_detected.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_permission_theft_prevented.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_process_injection_detected.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_process_injection_prevented.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_ransomware_detected.json # x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_ransomware_prevented.json Co-authored-by: Justin Ibarra --- .../prepackaged_rules/endgame_adversary_behavior_detected.json | 3 +-- .../rules/prepackaged_rules/endgame_cred_dumping_detected.json | 3 +-- .../prepackaged_rules/endgame_cred_dumping_prevented.json | 3 +-- .../prepackaged_rules/endgame_cred_manipulation_detected.json | 3 +-- .../prepackaged_rules/endgame_cred_manipulation_prevented.json | 3 +-- .../rules/prepackaged_rules/endgame_exploit_detected.json | 3 +-- .../rules/prepackaged_rules/endgame_exploit_prevented.json | 3 +-- .../rules/prepackaged_rules/endgame_malware_detected.json | 3 +-- .../rules/prepackaged_rules/endgame_malware_prevented.json | 3 +-- .../prepackaged_rules/endgame_permission_theft_detected.json | 3 +-- .../prepackaged_rules/endgame_permission_theft_prevented.json | 3 +-- .../prepackaged_rules/endgame_process_injection_detected.json | 3 +-- .../prepackaged_rules/endgame_process_injection_prevented.json | 3 +-- .../rules/prepackaged_rules/endgame_ransomware_detected.json | 3 +-- .../rules/prepackaged_rules/endgame_ransomware_prevented.json | 3 +-- 15 files changed, 15 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_adversary_behavior_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_adversary_behavior_detected.json index bf75f431da71e..bf53625cef750 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_adversary_behavior_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_adversary_behavior_detected.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_dumping_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_dumping_detected.json index 3bc78b5f9b333..43cb19f50d675 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_dumping_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_dumping_detected.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_dumping_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_dumping_prevented.json index 4c81580727c27..29b5bc3f39cf1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_dumping_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_dumping_prevented.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_manipulation_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_manipulation_detected.json index 530daa7d58624..393591a241114 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_manipulation_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_manipulation_detected.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_manipulation_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_manipulation_prevented.json index c23c7cc4e301c..e9ca199c4a791 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_manipulation_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_cred_manipulation_prevented.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_exploit_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_exploit_detected.json index fed4b0d87c54e..a169582c2da92 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_exploit_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_exploit_detected.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_exploit_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_exploit_prevented.json index 84c4c0356bb6c..b781a1fae1847 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_exploit_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_exploit_prevented.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_malware_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_malware_detected.json index 0bca9c21bcc3b..f7a064961f039 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_malware_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_malware_detected.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_malware_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_malware_prevented.json index 192c1b40e4c10..59cbd98e2d42b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_malware_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_malware_prevented.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_permission_theft_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_permission_theft_detected.json index 5f88961b724b8..b3db96d6d121b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_permission_theft_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_permission_theft_detected.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_permission_theft_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_permission_theft_prevented.json index 80745c301ca41..18b316a293da8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_permission_theft_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_permission_theft_prevented.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_process_injection_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_process_injection_detected.json index 2510abc2b7bf3..861daa2d004c7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_process_injection_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_process_injection_detected.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_process_injection_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_process_injection_prevented.json index 79c262a8d68f5..5f78a3517e931 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_process_injection_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_process_injection_prevented.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_ransomware_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_ransomware_detected.json index 6d378f712810f..4c060bb52f32f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_ransomware_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_ransomware_detected.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_ransomware_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_ransomware_prevented.json index 0e4373825c950..78845ffc4c845 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_ransomware_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endgame_ransomware_prevented.json @@ -19,7 +19,6 @@ "Elastic", "Elastic Endgame" ], - "timestamp_override": "event.ingested", "type": "query", - "version": 5 + "version": 6 } From 048b9f3c171fb14e54b9a166e3663bda342ce185 Mon Sep 17 00:00:00 2001 From: spalger Date: Thu, 8 Apr 2021 02:09:29 -0700 Subject: [PATCH 05/22] skip suite blocking es promotion (#96515) (cherry picked from commit f9317281d1ebdb8fb2aadf9f6a716cd776a51299) --- x-pack/test/fleet_api_integration/apis/agents_setup.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/fleet_api_integration/apis/agents_setup.ts b/x-pack/test/fleet_api_integration/apis/agents_setup.ts index 91d6ca0119d1d..d49bc91251b01 100644 --- a/x-pack/test/fleet_api_integration/apis/agents_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/agents_setup.ts @@ -15,7 +15,8 @@ export default function (providerContext: FtrProviderContext) { const es = getService('es'); const esArchiver = getService('esArchiver'); - describe('fleet_agents_setup', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 + describe.skip('fleet_agents_setup', () => { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('empty_kibana'); From 8589455db7e7368ccf70505bdb0965e14dcdb883 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 8 Apr 2021 06:23:19 -0400 Subject: [PATCH 06/22] [SECURITY SOLUTION] Add new exception list type and feature flag for event filtering (#96037) (#96485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * New exception list type for event filtering * New feature flag for event filtering Co-authored-by: David Sánchez Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/lists/common/schemas/common/schemas.ts | 7 ++++++- .../common/detection_engine/schemas/types/lists.test.ts | 6 +++--- .../security_solution/common/experimental_features.ts | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index e553b65a2f610..f261e4e3eefa6 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -212,13 +212,18 @@ export type Tags = t.TypeOf; export const tagsOrUndefined = t.union([tags, t.undefined]); export type TagsOrUndefined = t.TypeOf; -export const exceptionListType = t.keyof({ detection: null, endpoint: null }); +export const exceptionListType = t.keyof({ + detection: null, + endpoint: null, + endpoint_events: null, +}); export const exceptionListTypeOrUndefined = t.union([exceptionListType, t.undefined]); export type ExceptionListType = t.TypeOf; export type ExceptionListTypeOrUndefined = t.TypeOf; export enum ExceptionListTypeEnum { DETECTION = 'detection', ENDPOINT = 'endpoint', + ENDPOINT_EVENTS = 'endpoint_events', } export const exceptionListItemType = t.keyof({ simple: null }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts index e331eea51eec0..28b70f51742a7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts @@ -94,7 +94,7 @@ describe('Lists', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "1" supplied to "Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint", namespace_type: "agnostic" | "single" |}>"', + 'Invalid value "1" supplied to "Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint" | "endpoint_events", namespace_type: "agnostic" | "single" |}>"', ]); expect(message.schema).toEqual({}); }); @@ -125,8 +125,8 @@ describe('Lists', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "1" supplied to "(Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint", namespace_type: "agnostic" | "single" |}> | undefined)"', - 'Invalid value "[1]" supplied to "(Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint", namespace_type: "agnostic" | "single" |}> | undefined)"', + 'Invalid value "1" supplied to "(Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint" | "endpoint_events", namespace_type: "agnostic" | "single" |}> | undefined)"', + 'Invalid value "[1]" supplied to "(Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint" | "endpoint_events", namespace_type: "agnostic" | "single" |}> | undefined)"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 19de81cb95c3f..39551e3ee6f1c 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -14,6 +14,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; const allowedExperimentalValues = Object.freeze({ fleetServerEnabled: false, trustedAppsByPolicyEnabled: false, + eventFilteringEnabled: false, }); type ExperimentalConfigKeys = Array; From ed3d8b574d040270acf1ca6cb3da6b46cf64f84c Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 8 Apr 2021 07:34:28 -0400 Subject: [PATCH 07/22] Skip rendering empty add action variables button as disabled (#96342) (#96541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Skip rendering empty add action variables button * Fix jest tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Mike Côté --- .../components/add_message_variables.test.tsx | 12 ++++++++++++ .../application/components/add_message_variables.tsx | 7 +++++-- .../es_index/es_index_params.test.tsx | 7 +++++++ .../pagerduty/pagerduty_params.test.tsx | 7 +++++++ .../webhook/webhook_params.test.tsx | 7 +++++++ 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx index 8d27edd9e4bcc..4e03a2a09bed4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx @@ -117,4 +117,16 @@ describe('AddMessageVariables', () => { wrapper.find('button[data-test-subj="variableMenuButton-deprecatedVar"]').getDOMNode() ).toBeDisabled(); }); + + test(`it does't render when no variables exist`, () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="fooAddVariableButton"]')).toHaveLength(0); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx index bf89e4f6ae6e1..57b251fba0d45 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useState, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiPopover, @@ -61,13 +61,16 @@ export const AddMessageVariables: React.FunctionComponent = ({ } ); + if ((messageVariables?.length ?? 0) === 0) { + return ; + } + return ( setIsVariablesPopoverOpen(true)} iconType="indexOpen" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx index 97c1c41f68730..b792cf6574455 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx @@ -22,6 +22,13 @@ describe('IndexParamsFields renders', () => { errors={{ index: [] }} editAction={() => {}} index={0} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} /> ); expect(wrapper.find('[data-test-subj="documentsJsonEditor"]').first().prop('value')).toBe(`{ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx index 6e3f4213b7907..4d47cbf3685a1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx @@ -30,6 +30,13 @@ describe('PagerDutyParamsFields renders', () => { errors={{ summary: [], timestamp: [], dedupKey: [] }} editAction={() => {}} index={0} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} /> ); expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx index 801d9a6b43ec6..a3756ae74fd14 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx @@ -21,6 +21,13 @@ describe('WebhookParamsFields renders', () => { errors={{ body: [] }} editAction={() => {}} index={0} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} /> ); expect(wrapper.find('[data-test-subj="bodyJsonEditor"]').length > 0).toBeTruthy(); From e88148235deff84ba64d1514d40e012f599d5893 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 8 Apr 2021 08:43:05 -0400 Subject: [PATCH 08/22] [Telemetry] enforce import export type (#96487) (#96546) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: hardikpnsp --- packages/kbn-analytics/tsconfig.json | 1 + packages/kbn-telemetry-tools/src/tools/tasks/index.ts | 4 +++- packages/kbn-telemetry-tools/tsconfig.json | 3 ++- src/plugins/kibana_usage_collection/tsconfig.json | 3 ++- .../telemetry/common/telemetry_config/index.ts | 6 ++---- src/plugins/telemetry/public/index.ts | 2 +- src/plugins/telemetry/server/index.ts | 9 ++++++--- .../telemetry_collection/get_data_telemetry/index.ts | 9 ++------- .../telemetry/server/telemetry_collection/index.ts | 11 ++++------- .../telemetry/server/telemetry_repository/index.ts | 2 +- src/plugins/telemetry/tsconfig.json | 3 ++- .../telemetry_collection_manager/server/index.ts | 2 +- .../telemetry_collection_manager/tsconfig.json | 3 ++- .../telemetry_management_section/public/index.ts | 2 +- .../telemetry_management_section/tsconfig.json | 3 ++- src/plugins/usage_collection/public/index.ts | 2 +- .../usage_collection/server/collector/index.ts | 10 ++++++---- src/plugins/usage_collection/server/index.ts | 7 +++---- .../usage_collection/server/usage_collection.mock.ts | 3 ++- src/plugins/usage_collection/tsconfig.json | 3 ++- .../telemetry_collection_xpack/server/index.ts | 2 +- .../server/telemetry_collection/index.ts | 2 +- .../plugins/telemetry_collection_xpack/tsconfig.json | 3 ++- 23 files changed, 50 insertions(+), 45 deletions(-) diff --git a/packages/kbn-analytics/tsconfig.json b/packages/kbn-analytics/tsconfig.json index c2e579e7fdbea..80a2255d71805 100644 --- a/packages/kbn-analytics/tsconfig.json +++ b/packages/kbn-analytics/tsconfig.json @@ -7,6 +7,7 @@ "emitDeclarationOnly": true, "declaration": true, "declarationMap": true, + "isolatedModules": true, "sourceMap": true, "sourceRoot": "../../../../../packages/kbn-analytics/src", "types": [ diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/index.ts b/packages/kbn-telemetry-tools/src/tools/tasks/index.ts index 5d946b73d9759..f55a9aa80d40d 100644 --- a/packages/kbn-telemetry-tools/src/tools/tasks/index.ts +++ b/packages/kbn-telemetry-tools/src/tools/tasks/index.ts @@ -7,7 +7,9 @@ */ export { ErrorReporter } from './error_reporter'; -export { TaskContext, createTaskContext } from './task_context'; + +export type { TaskContext } from './task_context'; +export { createTaskContext } from './task_context'; export { parseConfigsTask } from './parse_configs_task'; export { extractCollectorsTask } from './extract_collectors_task'; diff --git a/packages/kbn-telemetry-tools/tsconfig.json b/packages/kbn-telemetry-tools/tsconfig.json index 39946fe9907e5..419af1d02f83b 100644 --- a/packages/kbn-telemetry-tools/tsconfig.json +++ b/packages/kbn-telemetry-tools/tsconfig.json @@ -6,7 +6,8 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "sourceRoot": "../../../../packages/kbn-telemetry-tools/src" + "sourceRoot": "../../../../packages/kbn-telemetry-tools/src", + "isolatedModules": true }, "include": [ "src/**/*", diff --git a/src/plugins/kibana_usage_collection/tsconfig.json b/src/plugins/kibana_usage_collection/tsconfig.json index d664d936f6667..ee07dfe589e4a 100644 --- a/src/plugins/kibana_usage_collection/tsconfig.json +++ b/src/plugins/kibana_usage_collection/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "common/*", diff --git a/src/plugins/telemetry/common/telemetry_config/index.ts b/src/plugins/telemetry/common/telemetry_config/index.ts index 84b6486f35b24..cc4ff102742d7 100644 --- a/src/plugins/telemetry/common/telemetry_config/index.ts +++ b/src/plugins/telemetry/common/telemetry_config/index.ts @@ -9,7 +9,5 @@ export { getTelemetryOptIn } from './get_telemetry_opt_in'; export { getTelemetrySendUsageFrom } from './get_telemetry_send_usage_from'; export { getTelemetryAllowChangingOptInStatus } from './get_telemetry_allow_changing_opt_in_status'; -export { - getTelemetryFailureDetails, - TelemetryFailureDetails, -} from './get_telemetry_failure_details'; +export { getTelemetryFailureDetails } from './get_telemetry_failure_details'; +export type { TelemetryFailureDetails } from './get_telemetry_failure_details'; diff --git a/src/plugins/telemetry/public/index.ts b/src/plugins/telemetry/public/index.ts index 6cca9bdf881dd..47ba7828eaec2 100644 --- a/src/plugins/telemetry/public/index.ts +++ b/src/plugins/telemetry/public/index.ts @@ -8,7 +8,7 @@ import { PluginInitializerContext } from 'kibana/public'; import { TelemetryPlugin, TelemetryPluginConfig } from './plugin'; -export { TelemetryPluginStart, TelemetryPluginSetup } from './plugin'; +export type { TelemetryPluginStart, TelemetryPluginSetup } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new TelemetryPlugin(initializerContext); diff --git a/src/plugins/telemetry/server/index.ts b/src/plugins/telemetry/server/index.ts index debdf7515cd58..1c335426ffd03 100644 --- a/src/plugins/telemetry/server/index.ts +++ b/src/plugins/telemetry/server/index.ts @@ -13,7 +13,7 @@ import { configSchema, TelemetryConfigType } from './config'; export { FetcherTask } from './fetcher'; export { handleOldSettings } from './handle_old_settings'; -export { TelemetryPluginSetup, TelemetryPluginStart } from './plugin'; +export type { TelemetryPluginSetup, TelemetryPluginStart } from './plugin'; export const config: PluginConfigDescriptor = { schema: configSchema, @@ -34,9 +34,12 @@ export { constants }; export { getClusterUuids, getLocalStats, - TelemetryLocalStats, DATA_TELEMETRY_ID, + buildDataTelemetryPayload, +} from './telemetry_collection'; + +export type { + TelemetryLocalStats, DataTelemetryIndex, DataTelemetryPayload, - buildDataTelemetryPayload, } from './telemetry_collection'; diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts index def1131dfb1a3..c93b7e872924b 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/index.ts @@ -7,10 +7,5 @@ */ export { DATA_TELEMETRY_ID } from './constants'; - -export { - getDataTelemetry, - buildDataTelemetryPayload, - DataTelemetryPayload, - DataTelemetryIndex, -} from './get_data_telemetry'; +export { getDataTelemetry, buildDataTelemetryPayload } from './get_data_telemetry'; +export type { DataTelemetryPayload, DataTelemetryIndex } from './get_data_telemetry'; diff --git a/src/plugins/telemetry/server/telemetry_collection/index.ts b/src/plugins/telemetry/server/telemetry_collection/index.ts index 55f9c7f0e624c..151e89a11a192 100644 --- a/src/plugins/telemetry/server/telemetry_collection/index.ts +++ b/src/plugins/telemetry/server/telemetry_collection/index.ts @@ -6,12 +6,9 @@ * Side Public License, v 1. */ -export { - DATA_TELEMETRY_ID, - DataTelemetryIndex, - DataTelemetryPayload, - buildDataTelemetryPayload, -} from './get_data_telemetry'; -export { getLocalStats, TelemetryLocalStats } from './get_local_stats'; +export { DATA_TELEMETRY_ID, buildDataTelemetryPayload } from './get_data_telemetry'; +export type { DataTelemetryIndex, DataTelemetryPayload } from './get_data_telemetry'; +export { getLocalStats } from './get_local_stats'; +export type { TelemetryLocalStats } from './get_local_stats'; export { getClusterUuids } from './get_cluster_stats'; export { registerCollection } from './register_collection'; diff --git a/src/plugins/telemetry/server/telemetry_repository/index.ts b/src/plugins/telemetry/server/telemetry_repository/index.ts index 4e3f046f7611f..594b53259a65f 100644 --- a/src/plugins/telemetry/server/telemetry_repository/index.ts +++ b/src/plugins/telemetry/server/telemetry_repository/index.ts @@ -8,7 +8,7 @@ export { getTelemetrySavedObject } from './get_telemetry_saved_object'; export { updateTelemetrySavedObject } from './update_telemetry_saved_object'; -export { +export type { TelemetrySavedObject, TelemetrySavedObjectAttributes, } from '../../common/telemetry_config/types'; diff --git a/src/plugins/telemetry/tsconfig.json b/src/plugins/telemetry/tsconfig.json index bdced01d9eb6f..6629e479906c9 100644 --- a/src/plugins/telemetry/tsconfig.json +++ b/src/plugins/telemetry/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "public/**/**/*", diff --git a/src/plugins/telemetry_collection_manager/server/index.ts b/src/plugins/telemetry_collection_manager/server/index.ts index 77077b73cf8ad..c0cd124a132c0 100644 --- a/src/plugins/telemetry_collection_manager/server/index.ts +++ b/src/plugins/telemetry_collection_manager/server/index.ts @@ -16,7 +16,7 @@ export function plugin(initializerContext: PluginInitializerContext) { return new TelemetryCollectionManagerPlugin(initializerContext); } -export { +export type { TelemetryCollectionManagerPluginSetup, TelemetryCollectionManagerPluginStart, StatsCollectionConfig, diff --git a/src/plugins/telemetry_collection_manager/tsconfig.json b/src/plugins/telemetry_collection_manager/tsconfig.json index 1bba81769f0dd..1329979860603 100644 --- a/src/plugins/telemetry_collection_manager/tsconfig.json +++ b/src/plugins/telemetry_collection_manager/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "server/**/*", diff --git a/src/plugins/telemetry_management_section/public/index.ts b/src/plugins/telemetry_management_section/public/index.ts index 28b04418f512d..db6ea17556ed3 100644 --- a/src/plugins/telemetry_management_section/public/index.ts +++ b/src/plugins/telemetry_management_section/public/index.ts @@ -10,7 +10,7 @@ import { TelemetryManagementSectionPlugin } from './plugin'; export { OptInExampleFlyout } from './components'; -export { TelemetryManagementSectionPluginSetup } from './plugin'; +export type { TelemetryManagementSectionPluginSetup } from './plugin'; export function plugin() { return new TelemetryManagementSectionPlugin(); } diff --git a/src/plugins/telemetry_management_section/tsconfig.json b/src/plugins/telemetry_management_section/tsconfig.json index 48e40814b8570..2daee868ac200 100644 --- a/src/plugins/telemetry_management_section/tsconfig.json +++ b/src/plugins/telemetry_management_section/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "public/**/*", diff --git a/src/plugins/usage_collection/public/index.ts b/src/plugins/usage_collection/public/index.ts index b9e0e0a8985b1..9b009b1d9e264 100644 --- a/src/plugins/usage_collection/public/index.ts +++ b/src/plugins/usage_collection/public/index.ts @@ -10,7 +10,7 @@ import { PluginInitializerContext } from '../../../core/public'; import { UsageCollectionPlugin } from './plugin'; export { METRIC_TYPE } from '@kbn/analytics'; -export { UsageCollectionSetup, UsageCollectionStart } from './plugin'; +export type { UsageCollectionSetup, UsageCollectionStart } from './plugin'; export { TrackApplicationView } from './components'; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/src/plugins/usage_collection/server/collector/index.ts b/src/plugins/usage_collection/server/collector/index.ts index 5f48f9fb93813..d5e0d95659e58 100644 --- a/src/plugins/usage_collection/server/collector/index.ts +++ b/src/plugins/usage_collection/server/collector/index.ts @@ -6,9 +6,10 @@ * Side Public License, v 1. */ -export { CollectorSet, CollectorSetPublic } from './collector_set'; -export { - Collector, +export { CollectorSet } from './collector_set'; +export type { CollectorSetPublic } from './collector_set'; +export { Collector } from './collector'; +export type { AllowedSchemaTypes, AllowedSchemaNumberTypes, SchemaField, @@ -16,4 +17,5 @@ export { CollectorOptions, CollectorFetchContext, } from './collector'; -export { UsageCollector, UsageCollectorOptions } from './usage_collector'; +export { UsageCollector } from './usage_collector'; +export type { UsageCollectorOptions } from './usage_collector'; diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index dfc9d19b69646..dd9e6644a827d 100644 --- a/src/plugins/usage_collection/server/index.ts +++ b/src/plugins/usage_collection/server/index.ts @@ -9,17 +9,16 @@ import { PluginInitializerContext } from 'src/core/server'; import { UsageCollectionPlugin } from './plugin'; -export { +export { Collector } from './collector'; +export type { AllowedSchemaTypes, MakeSchemaFrom, SchemaField, CollectorOptions, UsageCollectorOptions, - Collector, CollectorFetchContext, } from './collector'; - -export { UsageCollectionSetup } from './plugin'; +export type { UsageCollectionSetup } from './plugin'; export { config } from './config'; export const plugin = (initializerContext: PluginInitializerContext) => new UsageCollectionPlugin(initializerContext); diff --git a/src/plugins/usage_collection/server/usage_collection.mock.ts b/src/plugins/usage_collection/server/usage_collection.mock.ts index 1a60d84e7948c..7e3f4273bbea8 100644 --- a/src/plugins/usage_collection/server/usage_collection.mock.ts +++ b/src/plugins/usage_collection/server/usage_collection.mock.ts @@ -16,7 +16,8 @@ import { import { CollectorOptions, Collector, UsageCollector } from './collector'; import { UsageCollectionSetup, CollectorFetchContext } from './index'; -export { CollectorOptions, Collector }; +export type { CollectorOptions }; +export { Collector }; const logger = loggingSystemMock.createLogger(); diff --git a/src/plugins/usage_collection/tsconfig.json b/src/plugins/usage_collection/tsconfig.json index 96b2c4d37e17c..68a0853994e80 100644 --- a/src/plugins/usage_collection/tsconfig.json +++ b/src/plugins/usage_collection/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "public/**/*", diff --git a/x-pack/plugins/telemetry_collection_xpack/server/index.ts b/x-pack/plugins/telemetry_collection_xpack/server/index.ts index d924882e17fbd..aab1bdb58fe59 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/index.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/index.ts @@ -7,7 +7,7 @@ import { TelemetryCollectionXpackPlugin } from './plugin'; -export { ESLicense } from './telemetry_collection'; +export type { ESLicense } from './telemetry_collection'; // This exports static code and TypeScript types, // as well as, Kibana Platform `plugin()` initializer. diff --git a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts index 4599b068b9b38..c1a11caf44f24 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { ESLicense } from './get_license'; +export type { ESLicense } from './get_license'; export { getStatsWithXpack } from './get_stats_with_xpack'; diff --git a/x-pack/plugins/telemetry_collection_xpack/tsconfig.json b/x-pack/plugins/telemetry_collection_xpack/tsconfig.json index 476f5926f757a..1221200c7548c 100644 --- a/x-pack/plugins/telemetry_collection_xpack/tsconfig.json +++ b/x-pack/plugins/telemetry_collection_xpack/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "isolatedModules": true }, "include": [ "common/**/*", From 5ae94462603fc800fd2f5d0cee105f12d3794098 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 8 Apr 2021 09:16:58 -0400 Subject: [PATCH 09/22] [ML] Fix switches positioning on the Transform and DFA wizards (#96535) (#96549) * [ML] fix edit runtime mapping switch positioning * [ML] fix transform wizard switches Co-authored-by: Dima Arnautov --- .../components/runtime_mappings/runtime_mappings.tsx | 2 +- .../advanced_runtime_mappings_settings.tsx | 2 +- .../components/step_define/step_define_form.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx index d21bf67a1f51c..5b8fc82ef587b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/runtime_mappings/runtime_mappings.tsx @@ -131,7 +131,7 @@ export const RuntimeMappings: FC = ({ actions, state }) => { defaultMessage: 'Runtime mappings', })} > - + {isPopulatedObject(runtimeMappings) ? ( diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx index 277226c81c925..7965db99b335b 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx @@ -91,7 +91,7 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = defaultMessage: 'Runtime mappings', })} > - + {runtimeMappings !== undefined && Object.keys(runtimeMappings).length > 0 ? ( = React.memo((props) => { } > <> - + {/* Flex Column #1: Search Bar / Advanced Search Editor */} {searchItems.savedSearch === undefined && ( From d972b8e50bb80cc38dc328c3bc3fd5569f257a73 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 8 Apr 2021 09:52:29 -0400 Subject: [PATCH 10/22] [Alerting] Update feature privilege display names (#96083) (#96554) * Updating feature display names * Updating feature display names Co-authored-by: ymao1 --- x-pack/examples/alerting_example/server/plugin.ts | 2 +- x-pack/plugins/stack_alerts/server/feature.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/examples/alerting_example/server/plugin.ts b/x-pack/examples/alerting_example/server/plugin.ts index db9c996147c94..f6131679874db 100644 --- a/x-pack/examples/alerting_example/server/plugin.ts +++ b/x-pack/examples/alerting_example/server/plugin.ts @@ -33,7 +33,7 @@ export class AlertingExamplePlugin implements Plugin Date: Thu, 8 Apr 2021 15:57:04 +0200 Subject: [PATCH 11/22] [7.x] [APM] Extract server type utils to package (#96349) (#96552) --- package.json | 2 + packages/kbn-io-ts-utils/jest.config.js | 13 + packages/kbn-io-ts-utils/package.json | 13 + packages/kbn-io-ts-utils/src/index.ts | 11 + .../src}/json_rt/index.test.ts | 9 +- .../kbn-io-ts-utils/src}/json_rt/index.ts | 5 +- .../src/merge_rt}/index.test.ts | 25 +- .../kbn-io-ts-utils/src/merge_rt}/index.ts | 37 +- .../src}/strict_keys_rt/index.test.ts | 28 +- .../src}/strict_keys_rt/index.ts | 78 +-- packages/kbn-io-ts-utils/tsconfig.json | 19 + .../kbn-server-route-repository/README.md | 7 + .../jest.config.js | 13 + .../kbn-server-route-repository/package.json | 16 + .../src/create_server_route_factory.ts | 38 ++ .../src/create_server_route_repository.ts | 39 ++ .../src/decode_request_params.test.ts | 122 +++++ .../src/decode_request_params.ts | 43 ++ .../src/format_request.ts | 20 + .../kbn-server-route-repository/src/index.ts | 24 + .../src/parse_endpoint.ts | 22 + .../src/route_validation_object.ts | 20 + .../src/test_types.ts | 238 ++++++++ .../src/typings.ts | 192 +++++++ .../kbn-server-route-repository/tsconfig.json | 20 + .../apm/common/latency_aggregation_types.ts | 6 +- .../runtime_types/iso_to_epoch_rt/index.ts | 5 +- .../link_preview.test.tsx | 9 +- .../CustomizeUI/CustomLink/index.test.tsx | 4 +- .../service_overview.test.tsx | 25 +- ...pm_observability_overview_fetchers.test.ts | 6 +- .../apm/public/services/rest/callApmApiSpy.ts | 24 + .../public/services/rest/createCallApmApi.ts | 83 ++- x-pack/plugins/apm/server/index.ts | 6 +- .../create_es_client/call_async_with_debug.ts | 2 +- .../create_internal_es_client/index.ts | 11 +- .../server/lib/helpers/setup_request.test.ts | 127 ++--- .../apm/server/lib/helpers/setup_request.ts | 33 +- .../create_static_index_pattern.test.ts | 36 +- .../create_static_index_pattern.ts | 8 +- .../get_apm_index_pattern_title.ts | 8 +- .../get_dynamic_index_pattern.ts | 12 +- .../settings/apm_indices/get_apm_indices.ts | 9 +- x-pack/plugins/apm/server/plugin.ts | 100 ++-- .../apm/server/routes/alerts/chart_preview.ts | 40 +- .../plugins/apm/server/routes/correlations.ts | 47 +- .../server/routes/create_api/index.test.ts | 368 ------------- .../apm/server/routes/create_api/index.ts | 185 ------- .../apm/server/routes/create_apm_api.ts | 230 -------- .../server/routes/create_apm_server_route.ts | 13 + .../create_apm_server_route_repository.ts | 15 + .../plugins/apm/server/routes/create_route.ts | 29 - .../plugins/apm/server/routes/environments.ts | 16 +- x-pack/plugins/apm/server/routes/errors.ts | 35 +- .../get_global_apm_server_route_repository.ts | 82 +++ .../apm/server/routes/index_pattern.ts | 48 +- x-pack/plugins/apm/server/routes/metrics.ts | 15 +- .../server/routes/observability_overview.ts | 22 +- .../routes/register_routes/index.test.ts | 507 ++++++++++++++++++ .../server/routes/register_routes/index.ts | 143 +++++ .../plugins/apm/server/routes/rum_client.ts | 122 +++-- .../plugins/apm/server/routes/service_map.ts | 31 +- .../apm/server/routes/service_nodes.ts | 15 +- x-pack/plugins/apm/server/routes/services.ts | 214 +++++--- .../routes/settings/agent_configuration.ts | 99 ++-- .../routes/settings/anomaly_detection.ts | 35 +- .../apm/server/routes/settings/apm_indices.ts | 28 +- .../apm/server/routes/settings/custom_link.ts | 70 ++- x-pack/plugins/apm/server/routes/traces.ts | 37 +- .../plugins/apm/server/routes/transactions.ts | 106 ++-- x-pack/plugins/apm/server/routes/typings.ts | 188 ++----- x-pack/plugins/apm/server/types.ts | 164 ++++++ .../common/apm_api_supertest.ts | 19 +- .../tests/inspect/inspect.ts | 1 - .../instances_primary_statistics.ts | 7 +- yarn.lock | 8 + 76 files changed, 2822 insertions(+), 1685 deletions(-) create mode 100644 packages/kbn-io-ts-utils/jest.config.js create mode 100644 packages/kbn-io-ts-utils/package.json create mode 100644 packages/kbn-io-ts-utils/src/index.ts rename {x-pack/plugins/apm/common/runtime_types => packages/kbn-io-ts-utils/src}/json_rt/index.test.ts (85%) rename {x-pack/plugins/apm/common/runtime_types => packages/kbn-io-ts-utils/src}/json_rt/index.ts (74%) rename {x-pack/plugins/apm/common/runtime_types/merge => packages/kbn-io-ts-utils/src/merge_rt}/index.test.ts (66%) rename {x-pack/plugins/apm/common/runtime_types/merge => packages/kbn-io-ts-utils/src/merge_rt}/index.ts (62%) rename {x-pack/plugins/apm/common/runtime_types => packages/kbn-io-ts-utils/src}/strict_keys_rt/index.test.ts (77%) rename {x-pack/plugins/apm/common/runtime_types => packages/kbn-io-ts-utils/src}/strict_keys_rt/index.ts (66%) create mode 100644 packages/kbn-io-ts-utils/tsconfig.json create mode 100644 packages/kbn-server-route-repository/README.md create mode 100644 packages/kbn-server-route-repository/jest.config.js create mode 100644 packages/kbn-server-route-repository/package.json create mode 100644 packages/kbn-server-route-repository/src/create_server_route_factory.ts create mode 100644 packages/kbn-server-route-repository/src/create_server_route_repository.ts create mode 100644 packages/kbn-server-route-repository/src/decode_request_params.test.ts create mode 100644 packages/kbn-server-route-repository/src/decode_request_params.ts create mode 100644 packages/kbn-server-route-repository/src/format_request.ts create mode 100644 packages/kbn-server-route-repository/src/index.ts create mode 100644 packages/kbn-server-route-repository/src/parse_endpoint.ts create mode 100644 packages/kbn-server-route-repository/src/route_validation_object.ts create mode 100644 packages/kbn-server-route-repository/src/test_types.ts create mode 100644 packages/kbn-server-route-repository/src/typings.ts create mode 100644 packages/kbn-server-route-repository/tsconfig.json create mode 100644 x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts delete mode 100644 x-pack/plugins/apm/server/routes/create_api/index.test.ts delete mode 100644 x-pack/plugins/apm/server/routes/create_api/index.ts delete mode 100644 x-pack/plugins/apm/server/routes/create_apm_api.ts create mode 100644 x-pack/plugins/apm/server/routes/create_apm_server_route.ts create mode 100644 x-pack/plugins/apm/server/routes/create_apm_server_route_repository.ts delete mode 100644 x-pack/plugins/apm/server/routes/create_route.ts create mode 100644 x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts create mode 100644 x-pack/plugins/apm/server/routes/register_routes/index.test.ts create mode 100644 x-pack/plugins/apm/server/routes/register_routes/index.ts create mode 100644 x-pack/plugins/apm/server/types.ts diff --git a/package.json b/package.json index 1aa32375411af..623247b4fd1a1 100644 --- a/package.json +++ b/package.json @@ -128,10 +128,12 @@ "@kbn/crypto": "link:packages/kbn-crypto", "@kbn/i18n": "link:packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", + "@kbn/io-ts-utils": "link:packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:packages/kbn-legacy-logging", "@kbn/logging": "link:packages/kbn-logging", "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/server-http-tools": "link:packages/kbn-server-http-tools", + "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", "@kbn/std": "link:packages/kbn-std", "@kbn/tinymath": "link:packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", diff --git a/packages/kbn-io-ts-utils/jest.config.js b/packages/kbn-io-ts-utils/jest.config.js new file mode 100644 index 0000000000000..1a71166fae843 --- /dev/null +++ b/packages/kbn-io-ts-utils/jest.config.js @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-io-ts-utils'], +}; diff --git a/packages/kbn-io-ts-utils/package.json b/packages/kbn-io-ts-utils/package.json new file mode 100644 index 0000000000000..4d6f02d3f85a6 --- /dev/null +++ b/packages/kbn-io-ts-utils/package.json @@ -0,0 +1,13 @@ +{ + "name": "@kbn/io-ts-utils", + "main": "./target/index.js", + "types": "./target/index.d.ts", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true, + "scripts": { + "build": "../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + } +} diff --git a/packages/kbn-io-ts-utils/src/index.ts b/packages/kbn-io-ts-utils/src/index.ts new file mode 100644 index 0000000000000..2032127b1eb91 --- /dev/null +++ b/packages/kbn-io-ts-utils/src/index.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 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 { jsonRt } from './json_rt'; +export { mergeRt } from './merge_rt'; +export { strictKeysRt } from './strict_keys_rt'; diff --git a/x-pack/plugins/apm/common/runtime_types/json_rt/index.test.ts b/packages/kbn-io-ts-utils/src/json_rt/index.test.ts similarity index 85% rename from x-pack/plugins/apm/common/runtime_types/json_rt/index.test.ts rename to packages/kbn-io-ts-utils/src/json_rt/index.test.ts index d6c286c672d90..1220639fc7bef 100644 --- a/x-pack/plugins/apm/common/runtime_types/json_rt/index.test.ts +++ b/packages/kbn-io-ts-utils/src/json_rt/index.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; @@ -12,9 +13,7 @@ import { Right } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; -function getValueOrThrow>( - either: TEither -): Right { +function getValueOrThrow>(either: TEither): Right { const value = pipe( either, fold(() => { diff --git a/x-pack/plugins/apm/common/runtime_types/json_rt/index.ts b/packages/kbn-io-ts-utils/src/json_rt/index.ts similarity index 74% rename from x-pack/plugins/apm/common/runtime_types/json_rt/index.ts rename to packages/kbn-io-ts-utils/src/json_rt/index.ts index 0207145a17be7..bc596d53db54c 100644 --- a/x-pack/plugins/apm/common/runtime_types/json_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/json_rt/index.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; diff --git a/x-pack/plugins/apm/common/runtime_types/merge/index.test.ts b/packages/kbn-io-ts-utils/src/merge_rt/index.test.ts similarity index 66% rename from x-pack/plugins/apm/common/runtime_types/merge/index.test.ts rename to packages/kbn-io-ts-utils/src/merge_rt/index.test.ts index af5a0221662d5..b25d4451895f2 100644 --- a/x-pack/plugins/apm/common/runtime_types/merge/index.test.ts +++ b/packages/kbn-io-ts-utils/src/merge_rt/index.test.ts @@ -1,18 +1,19 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 t from 'io-ts'; import { isLeft } from 'fp-ts/lib/Either'; -import { merge } from './'; +import { mergeRt } from '.'; import { jsonRt } from '../json_rt'; describe('merge', () => { it('fails on one or more errors', () => { - const type = merge([t.type({ foo: t.string }), t.type({ bar: t.number })]); + const type = mergeRt(t.type({ foo: t.string }), t.type({ bar: t.number })); const result = type.decode({ foo: '' }); @@ -20,10 +21,7 @@ describe('merge', () => { }); it('merges left to right', () => { - const typeBoolean = merge([ - t.type({ foo: t.string }), - t.type({ foo: jsonRt.pipe(t.boolean) }), - ]); + const typeBoolean = mergeRt(t.type({ foo: t.string }), t.type({ foo: jsonRt.pipe(t.boolean) })); const resultBoolean = typeBoolean.decode({ foo: 'true', @@ -34,10 +32,7 @@ describe('merge', () => { foo: true, }); - const typeString = merge([ - t.type({ foo: jsonRt.pipe(t.boolean) }), - t.type({ foo: t.string }), - ]); + const typeString = mergeRt(t.type({ foo: jsonRt.pipe(t.boolean) }), t.type({ foo: t.string })); const resultString = typeString.decode({ foo: 'true', @@ -50,10 +45,10 @@ describe('merge', () => { }); it('deeply merges values', () => { - const type = merge([ + const type = mergeRt( t.type({ foo: t.type({ baz: t.string }) }), - t.type({ foo: t.type({ bar: t.string }) }), - ]); + t.type({ foo: t.type({ bar: t.string }) }) + ); const result = type.decode({ foo: { diff --git a/x-pack/plugins/apm/common/runtime_types/merge/index.ts b/packages/kbn-io-ts-utils/src/merge_rt/index.ts similarity index 62% rename from x-pack/plugins/apm/common/runtime_types/merge/index.ts rename to packages/kbn-io-ts-utils/src/merge_rt/index.ts index 451edf678aabe..c582767fb5101 100644 --- a/x-pack/plugins/apm/common/runtime_types/merge/index.ts +++ b/packages/kbn-io-ts-utils/src/merge_rt/index.ts @@ -1,31 +1,40 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; import { merge as lodashMerge } from 'lodash'; import { isLeft } from 'fp-ts/lib/Either'; -import { ValuesType } from 'utility-types'; -export type MergeType< - T extends t.Any[], - U extends ValuesType = ValuesType -> = t.Type & { - _tag: 'MergeType'; - types: T; -}; +type PlainObject = Record; + +type DeepMerge = U extends PlainObject + ? T extends PlainObject + ? Omit & + { + [key in keyof U]: T extends { [k in key]: any } ? DeepMerge : U[key]; + } + : U + : U; // this is similar to t.intersection, but does a deep merge // instead of a shallow merge -export function merge( - types: [A, B] -): MergeType<[A, B]>; +export type MergeType = t.Type< + DeepMerge, t.TypeOf>, + DeepMerge, t.OutputOf> +> & { + _tag: 'MergeType'; + types: [T1, T2]; +}; + +export function mergeRt(a: T1, b: T2): MergeType; -export function merge(types: t.Any[]) { +export function mergeRt(...types: t.Any[]) { const mergeType = new t.Type( 'merge', (u): u is unknown => { diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts similarity index 77% rename from x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts rename to packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts index 4212e0430ff5f..ab20ca42a283e 100644 --- a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts +++ b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; @@ -14,10 +15,7 @@ describe('strictKeysRt', () => { it('correctly and deeply validates object keys', () => { const checks: Array<{ type: t.Type; passes: any[]; fails: any[] }> = [ { - type: t.intersection([ - t.type({ foo: t.string }), - t.partial({ bar: t.string }), - ]), + type: t.intersection([t.type({ foo: t.string }), t.partial({ bar: t.string })]), passes: [{ foo: '' }, { foo: '', bar: '' }], fails: [ { foo: '', unknownKey: '' }, @@ -26,15 +24,9 @@ describe('strictKeysRt', () => { }, { type: t.type({ - path: t.union([ - t.type({ serviceName: t.string }), - t.type({ transactionType: t.string }), - ]), + path: t.union([t.type({ serviceName: t.string }), t.type({ transactionType: t.string })]), }), - passes: [ - { path: { serviceName: '' } }, - { path: { transactionType: '' } }, - ], + passes: [{ path: { serviceName: '' } }, { path: { transactionType: '' } }], fails: [ { path: { serviceName: '', unknownKey: '' } }, { path: { transactionType: '', unknownKey: '' } }, @@ -62,9 +54,7 @@ describe('strictKeysRt', () => { if (!isRight(result)) { throw new Error( - `Expected ${JSON.stringify( - value - )} to be allowed, but validation failed with ${ + `Expected ${JSON.stringify(value)} to be allowed, but validation failed with ${ result.left[0].message }` ); @@ -76,9 +66,7 @@ describe('strictKeysRt', () => { if (!isLeft(result)) { throw new Error( - `Expected ${JSON.stringify( - value - )} to be disallowed, but validation succeeded` + `Expected ${JSON.stringify(value)} to be disallowed, but validation succeeded` ); } }); diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts similarity index 66% rename from x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts rename to packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts index e90ccf7eb8d31..56afdf54463f7 100644 --- a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts @@ -1,14 +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. + * 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 t from 'io-ts'; import { either, isRight } from 'fp-ts/lib/Either'; import { mapValues, difference, isPlainObject, forEach } from 'lodash'; -import { MergeType, merge } from '../merge'; +import { MergeType, mergeRt } from '../merge_rt'; /* Type that tracks validated keys, and fails when the input value @@ -21,7 +22,7 @@ type ParsableType = | t.PartialType | t.ExactType | t.InterfaceType - | MergeType; + | MergeType; function getKeysInObject>( object: T, @@ -32,17 +33,16 @@ function getKeysInObject>( const ownPrefix = prefix ? `${prefix}.${key}` : key; keys.push(ownPrefix); if (isPlainObject(object[key])) { - keys.push( - ...getKeysInObject(object[key] as Record, ownPrefix) - ); + keys.push(...getKeysInObject(object[key] as Record, ownPrefix)); } }); return keys; } -function addToContextWhenValidated< - T extends t.InterfaceType | t.PartialType ->(type: T, prefix: string): T { +function addToContextWhenValidated | t.PartialType>( + type: T, + prefix: string +): T { const validate = (input: unknown, context: t.Context) => { const result = type.validate(input, context); const keysType = context[0].type as StrictKeysType; @@ -50,36 +50,19 @@ function addToContextWhenValidated< throw new Error('Expected a top-level StrictKeysType'); } if (isRight(result)) { - keysType.trackedKeys.push( - ...Object.keys(type.props).map((propKey) => `${prefix}${propKey}`) - ); + keysType.trackedKeys.push(...Object.keys(type.props).map((propKey) => `${prefix}${propKey}`)); } return result; }; if (type._tag === 'InterfaceType') { - return new t.InterfaceType( - type.name, - type.is, - validate, - type.encode, - type.props - ) as T; + return new t.InterfaceType(type.name, type.is, validate, type.encode, type.props) as T; } - return new t.PartialType( - type.name, - type.is, - validate, - type.encode, - type.props - ) as T; + return new t.PartialType(type.name, type.is, validate, type.encode, type.props) as T; } -function trackKeysOfValidatedTypes( - type: ParsableType | t.Any, - prefix: string = '' -): t.Any { +function trackKeysOfValidatedTypes(type: ParsableType | t.Any, prefix: string = ''): t.Any { if (!('_tag' in type)) { return type; } @@ -89,27 +72,24 @@ function trackKeysOfValidatedTypes( case 'IntersectionType': { const collectionType = type as t.IntersectionType; return t.intersection( - collectionType.types.map((rt) => - trackKeysOfValidatedTypes(rt, prefix) - ) as [t.Any, t.Any] + collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any] ); } case 'UnionType': { const collectionType = type as t.UnionType; return t.union( - collectionType.types.map((rt) => - trackKeysOfValidatedTypes(rt, prefix) - ) as [t.Any, t.Any] + collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any] ); } case 'MergeType': { - const collectionType = type as MergeType; - return merge( - collectionType.types.map((rt) => - trackKeysOfValidatedTypes(rt, prefix) - ) as [t.Any, t.Any] + const collectionType = type as MergeType; + return mergeRt( + ...(collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [ + t.Any, + t.Any + ]) ); } @@ -142,9 +122,7 @@ function trackKeysOfValidatedTypes( case 'ExactType': { const exactType = type as t.ExactType; - return t.exact( - trackKeysOfValidatedTypes(exactType.type, prefix) as t.HasProps - ); + return t.exact(trackKeysOfValidatedTypes(exactType.type, prefix) as t.HasProps); } default: @@ -169,17 +147,11 @@ class StrictKeysType< (input, context) => { this.trackedKeys.length = 0; return either.chain(trackedType.validate(input, context), (i) => { - const originalKeys = getKeysInObject( - input as Record - ); + const originalKeys = getKeysInObject(input as Record); const excessKeys = difference(originalKeys, this.trackedKeys); if (excessKeys.length) { - return t.failure( - i, - context, - `Excess keys are not allowed: \n${excessKeys.join('\n')}` - ); + return t.failure(i, context, `Excess keys are not allowed: \n${excessKeys.join('\n')}`); } return t.success(i); diff --git a/packages/kbn-io-ts-utils/tsconfig.json b/packages/kbn-io-ts-utils/tsconfig.json new file mode 100644 index 0000000000000..6c67518e21073 --- /dev/null +++ b/packages/kbn-io-ts-utils/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "incremental": false, + "outDir": "./target", + "stripInternal": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-io-ts-utils/src", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "./src/**/*.ts" + ] +} diff --git a/packages/kbn-server-route-repository/README.md b/packages/kbn-server-route-repository/README.md new file mode 100644 index 0000000000000..e22205540ef31 --- /dev/null +++ b/packages/kbn-server-route-repository/README.md @@ -0,0 +1,7 @@ +# @kbn/server-route-repository + +Utility functions for creating a typed server route repository, and a typed client, generating runtime validation and type validation from the same route definition. + +## Usage + +TBD diff --git a/packages/kbn-server-route-repository/jest.config.js b/packages/kbn-server-route-repository/jest.config.js new file mode 100644 index 0000000000000..7449bb7cd3860 --- /dev/null +++ b/packages/kbn-server-route-repository/jest.config.js @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-server-route-repository'], +}; diff --git a/packages/kbn-server-route-repository/package.json b/packages/kbn-server-route-repository/package.json new file mode 100644 index 0000000000000..ce1ca02d0c4f6 --- /dev/null +++ b/packages/kbn-server-route-repository/package.json @@ -0,0 +1,16 @@ +{ + "name": "@kbn/server-route-repository", + "main": "./target/index.js", + "types": "./target/index.d.ts", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true, + "scripts": { + "build": "../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + }, + "dependencies": { + "@kbn/io-ts-utils": "link:../kbn-io-ts-utils" + } +} diff --git a/packages/kbn-server-route-repository/src/create_server_route_factory.ts b/packages/kbn-server-route-repository/src/create_server_route_factory.ts new file mode 100644 index 0000000000000..edf9bd657f995 --- /dev/null +++ b/packages/kbn-server-route-repository/src/create_server_route_factory.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { + ServerRouteCreateOptions, + ServerRouteHandlerResources, + RouteParamsRT, + ServerRoute, +} from './typings'; + +export function createServerRouteFactory< + TRouteHandlerResources extends ServerRouteHandlerResources, + TRouteCreateOptions extends ServerRouteCreateOptions +>(): < + TEndpoint extends string, + TReturnType, + TRouteParamsRT extends RouteParamsRT | undefined = undefined +>( + route: ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions + > +) => ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions +> { + return (route) => route; +} diff --git a/packages/kbn-server-route-repository/src/create_server_route_repository.ts b/packages/kbn-server-route-repository/src/create_server_route_repository.ts new file mode 100644 index 0000000000000..5ac89ebcac77f --- /dev/null +++ b/packages/kbn-server-route-repository/src/create_server_route_repository.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { + ServerRouteHandlerResources, + ServerRouteRepository, + ServerRouteCreateOptions, +} from './typings'; + +export function createServerRouteRepository< + TRouteHandlerResources extends ServerRouteHandlerResources = never, + TRouteCreateOptions extends ServerRouteCreateOptions = never +>(): ServerRouteRepository { + let routes: Record = {}; + + return { + add(route) { + routes = { + ...routes, + [route.endpoint]: route, + }; + + return this as any; + }, + merge(repository) { + routes = { + ...routes, + ...Object.fromEntries(repository.getRoutes().map((route) => [route.endpoint, route])), + }; + + return this as any; + }, + getRoutes: () => Object.values(routes), + }; +} diff --git a/packages/kbn-server-route-repository/src/decode_request_params.test.ts b/packages/kbn-server-route-repository/src/decode_request_params.test.ts new file mode 100644 index 0000000000000..08ef303ad0b3a --- /dev/null +++ b/packages/kbn-server-route-repository/src/decode_request_params.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 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 { jsonRt } from '@kbn/io-ts-utils'; +import * as t from 'io-ts'; +import { decodeRequestParams } from './decode_request_params'; + +describe('decodeRequestParams', () => { + it('decodes request params', () => { + const decode = () => { + return decodeRequestParams( + { + params: { + serviceName: 'opbeans-java', + }, + body: null, + query: { + start: '', + }, + }, + t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.type({ + start: t.string, + }), + }) + ); + }; + expect(decode).not.toThrow(); + + expect(decode()).toEqual({ + path: { + serviceName: 'opbeans-java', + }, + query: { + start: '', + }, + }); + }); + + it('fails on excess keys', () => { + const decode = () => { + return decodeRequestParams( + { + params: { + serviceName: 'opbeans-java', + extraKey: '', + }, + body: null, + query: { + start: '', + }, + }, + t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.type({ + start: t.string, + }), + }) + ); + }; + + expect(decode).toThrowErrorMatchingInlineSnapshot(` + "Excess keys are not allowed: + path.extraKey" + `); + }); + + it('returns the decoded output', () => { + const decode = () => { + return decodeRequestParams( + { + params: {}, + query: { + _inspect: 'true', + }, + body: null, + }, + t.type({ + query: t.type({ + _inspect: jsonRt.pipe(t.boolean), + }), + }) + ); + }; + + expect(decode).not.toThrow(); + + expect(decode()).toEqual({ + query: { + _inspect: true, + }, + }); + }); + + it('strips empty params', () => { + const decode = () => { + return decodeRequestParams( + { + params: {}, + query: {}, + body: {}, + }, + t.type({ + body: t.any, + }) + ); + }; + + expect(decode).not.toThrow(); + + expect(decode()).toEqual({}); + }); +}); diff --git a/packages/kbn-server-route-repository/src/decode_request_params.ts b/packages/kbn-server-route-repository/src/decode_request_params.ts new file mode 100644 index 0000000000000..00492d69b8ac5 --- /dev/null +++ b/packages/kbn-server-route-repository/src/decode_request_params.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 t from 'io-ts'; +import { omitBy, isPlainObject, isEmpty } from 'lodash'; +import { isLeft } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import Boom from '@hapi/boom'; +import { strictKeysRt } from '@kbn/io-ts-utils'; +import { RouteParamsRT } from './typings'; + +interface KibanaRequestParams { + body: unknown; + query: unknown; + params: unknown; +} + +export function decodeRequestParams( + params: KibanaRequestParams, + paramsRt: T +): t.OutputOf { + const paramMap = omitBy( + { + path: params.params, + body: params.body, + query: params.query, + }, + (val) => val === null || val === undefined || (isPlainObject(val) && isEmpty(val)) + ); + + // decode = validate + const result = strictKeysRt(paramsRt).decode(paramMap); + + if (isLeft(result)) { + throw Boom.badRequest(PathReporter.report(result)[0]); + } + + return result.right; +} diff --git a/packages/kbn-server-route-repository/src/format_request.ts b/packages/kbn-server-route-repository/src/format_request.ts new file mode 100644 index 0000000000000..49004a78ce0e0 --- /dev/null +++ b/packages/kbn-server-route-repository/src/format_request.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 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 { parseEndpoint } from './parse_endpoint'; + +export function formatRequest(endpoint: string, pathParams: Record = {}) { + const { method, pathname: rawPathname } = parseEndpoint(endpoint); + + // replace template variables with path params + const pathname = Object.keys(pathParams).reduce((acc, paramName) => { + return acc.replace(`{${paramName}}`, pathParams[paramName]); + }, rawPathname); + + return { method, pathname }; +} diff --git a/packages/kbn-server-route-repository/src/index.ts b/packages/kbn-server-route-repository/src/index.ts new file mode 100644 index 0000000000000..23621c5b213bc --- /dev/null +++ b/packages/kbn-server-route-repository/src/index.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. + */ + +export { createServerRouteRepository } from './create_server_route_repository'; +export { createServerRouteFactory } from './create_server_route_factory'; +export { formatRequest } from './format_request'; +export { parseEndpoint } from './parse_endpoint'; +export { decodeRequestParams } from './decode_request_params'; +export { routeValidationObject } from './route_validation_object'; +export { + RouteRepositoryClient, + ReturnOf, + EndpointOf, + ClientRequestParamsOf, + DecodedRequestParamsOf, + ServerRouteRepository, + ServerRoute, + RouteParamsRT, +} from './typings'; diff --git a/packages/kbn-server-route-repository/src/parse_endpoint.ts b/packages/kbn-server-route-repository/src/parse_endpoint.ts new file mode 100644 index 0000000000000..fd40489b0f4a5 --- /dev/null +++ b/packages/kbn-server-route-repository/src/parse_endpoint.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +type Method = 'get' | 'post' | 'put' | 'delete'; + +export function parseEndpoint(endpoint: string) { + const parts = endpoint.split(' '); + + const method = parts[0].trim().toLowerCase() as Method; + const pathname = parts[1].trim(); + + if (!['get', 'post', 'put', 'delete'].includes(method)) { + throw new Error('Endpoint was not prefixed with a valid HTTP method'); + } + + return { method, pathname }; +} diff --git a/packages/kbn-server-route-repository/src/route_validation_object.ts b/packages/kbn-server-route-repository/src/route_validation_object.ts new file mode 100644 index 0000000000000..550be8d20d446 --- /dev/null +++ b/packages/kbn-server-route-repository/src/route_validation_object.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 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 { schema } from '@kbn/config-schema'; + +const anyObject = schema.object({}, { unknowns: 'allow' }); + +export const routeValidationObject = { + // `body` can be null, but `validate` expects non-nullable types + // if any validation is defined. Not having validation currently + // means we don't get the payload. See + // https://github.com/elastic/kibana/issues/50179 + body: schema.nullable(anyObject), + params: anyObject, + query: anyObject, +}; diff --git a/packages/kbn-server-route-repository/src/test_types.ts b/packages/kbn-server-route-repository/src/test_types.ts new file mode 100644 index 0000000000000..c9015e19b82f8 --- /dev/null +++ b/packages/kbn-server-route-repository/src/test_types.ts @@ -0,0 +1,238 @@ +/* + * Copyright 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 t from 'io-ts'; +import { createServerRouteRepository } from './create_server_route_repository'; +import { decodeRequestParams } from './decode_request_params'; +import { EndpointOf, ReturnOf, RouteRepositoryClient } from './typings'; + +function assertType(value: TShape) { + return value; +} + +// Generic arguments for createServerRouteRepository should be set, +// if not, registering routes should not be allowed +createServerRouteRepository().add({ + // @ts-expect-error + endpoint: 'any_endpoint', + // @ts-expect-error + handler: async ({ params }) => {}, +}); + +// If a params codec is not set, its type should not be available in +// the request handler. +createServerRouteRepository<{}, {}>().add({ + endpoint: 'endpoint_without_params', + handler: async (resources) => { + // @ts-expect-error Argument of type '{}' is not assignable to parameter of type '{ params: any; }'. + assertType<{ params: any }>(resources); + }, +}); + +// If a params codec is set, its type _should_ be available in the +// request handler. +createServerRouteRepository<{}, {}>().add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + handler: async (resources) => { + assertType<{ params: { path: { serviceName: string } } }>(resources); + }, +}); + +// Resources should be passed to the request handler. +createServerRouteRepository<{ context: { getSpaceId: () => string } }, {}>().add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + handler: async ({ context }) => { + const spaceId = context.getSpaceId(); + assertType(spaceId); + }, +}); + +// Create options are available when registering a route. +createServerRouteRepository<{}, { options: { tags: string[] } }>().add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + options: { + tags: [], + }, + handler: async (resources) => { + assertType<{ params: { path: { serviceName: string } } }>(resources); + }, +}); + +const repository = createServerRouteRepository<{}, {}>() + .add({ + endpoint: 'endpoint_without_params', + handler: async () => { + return { + noParamsForMe: true, + }; + }, + }) + .add({ + endpoint: 'endpoint_with_params', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + handler: async () => { + return { + yesParamsForMe: true, + }; + }, + }) + .add({ + endpoint: 'endpoint_with_optional_params', + params: t.partial({ + query: t.partial({ + serviceName: t.string, + }), + }), + handler: async () => { + return { + someParamsForMe: true, + }; + }, + }); + +type TestRepository = typeof repository; + +// EndpointOf should return all valid endpoints of a repository + +assertType>>([ + 'endpoint_with_params', + 'endpoint_without_params', + 'endpoint_with_optional_params', +]); + +// @ts-expect-error Type '"this_endpoint_does_not_exist"' is not assignable to type '"endpoint_without_params" | "endpoint_with_params" | "endpoint_with_optional_params"' +assertType>>(['this_endpoint_does_not_exist']); + +// ReturnOf should return the return type of a request handler. + +assertType>({ + noParamsForMe: true, +}); + +const noParamsInvalid: ReturnOf = { + // @ts-expect-error type '{ paramsForMe: boolean; }' is not assignable to type '{ noParamsForMe: boolean; }'. + paramsForMe: true, +}; + +// RouteRepositoryClient + +type TestClient = RouteRepositoryClient; + +const client: TestClient = {} as any; + +// It should respect any additional create options. + +// @ts-expect-error Property 'timeout' is missing +client({ + endpoint: 'endpoint_without_params', +}); + +client({ + endpoint: 'endpoint_without_params', + timeout: 1, +}); + +// It does not allow params for routes without a params codec +client({ + endpoint: 'endpoint_without_params', + // @ts-expect-error Object literal may only specify known properties, and 'params' does not exist in type + params: {}, + timeout: 1, +}); + +// It requires params for routes with a params codec +client({ + endpoint: 'endpoint_with_params', + params: { + // @ts-expect-error property 'serviceName' is missing in type '{}' + path: {}, + }, + timeout: 1, +}); + +// Params are optional if the codec has no required keys +client({ + endpoint: 'endpoint_with_optional_params', + timeout: 1, +}); + +// If optional, an error will still occur if the params do not match +client({ + endpoint: 'endpoint_with_optional_params', + timeout: 1, + params: { + // @ts-expect-error Object literal may only specify known properties, and 'path' does not exist in type + path: '', + }, +}); + +// The return type is correctly inferred +client({ + endpoint: 'endpoint_with_params', + params: { + path: { + serviceName: '', + }, + }, + timeout: 1, +}).then((res) => { + assertType<{ + noParamsForMe: boolean; + // @ts-expect-error Property 'noParamsForMe' is missing in type + }>(res); + + assertType<{ + yesParamsForMe: boolean; + }>(res); +}); + +// decodeRequestParams should return the type of the codec that is passed +assertType<{ path: { serviceName: string } }>( + decodeRequestParams( + { + params: { + serviceName: 'serviceName', + }, + body: undefined, + query: undefined, + }, + t.type({ path: t.type({ serviceName: t.string }) }) + ) +); + +assertType<{ path: { serviceName: boolean } }>( + // @ts-expect-error The types of 'path.serviceName' are incompatible between these types. + decodeRequestParams( + { + params: { + serviceName: 'serviceName', + }, + body: undefined, + query: undefined, + }, + t.type({ path: t.type({ serviceName: t.string }) }) + ) +); diff --git a/packages/kbn-server-route-repository/src/typings.ts b/packages/kbn-server-route-repository/src/typings.ts new file mode 100644 index 0000000000000..c27f67c71e88b --- /dev/null +++ b/packages/kbn-server-route-repository/src/typings.ts @@ -0,0 +1,192 @@ +/* + * Copyright 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 t from 'io-ts'; +import { RequiredKeys } from 'utility-types'; + +type MaybeOptional }> = RequiredKeys< + T['params'] +> extends never + ? { params?: T['params'] } + : { params: T['params'] }; + +type WithoutIncompatibleMethods = Omit & { + encode: t.Encode; + asEncoder: () => t.Encoder; +}; + +export type RouteParamsRT = WithoutIncompatibleMethods< + t.Type<{ + path?: any; + query?: any; + body?: any; + }> +>; + +export interface RouteState { + [endpoint: string]: ServerRoute; +} + +export type ServerRouteHandlerResources = Record; +export type ServerRouteCreateOptions = Record; + +export type ServerRoute< + TEndpoint extends string, + TRouteParamsRT extends RouteParamsRT | undefined, + TRouteHandlerResources extends ServerRouteHandlerResources, + TReturnType, + TRouteCreateOptions extends ServerRouteCreateOptions +> = { + endpoint: TEndpoint; + params?: TRouteParamsRT; + handler: ({}: TRouteHandlerResources & + (TRouteParamsRT extends RouteParamsRT + ? DecodedRequestParamsOfType + : {})) => Promise; +} & TRouteCreateOptions; + +export interface ServerRouteRepository< + TRouteHandlerResources extends ServerRouteHandlerResources = ServerRouteHandlerResources, + TRouteCreateOptions extends ServerRouteCreateOptions = ServerRouteCreateOptions, + TRouteState extends RouteState = RouteState +> { + add< + TEndpoint extends string, + TReturnType, + TRouteParamsRT extends RouteParamsRT | undefined = undefined + >( + route: ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions + > + ): ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions, + TRouteState & + { + [key in TEndpoint]: ServerRoute< + TEndpoint, + TRouteParamsRT, + TRouteHandlerResources, + TReturnType, + TRouteCreateOptions + >; + } + >; + merge< + TServerRouteRepository extends ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions + > + >( + repository: TServerRouteRepository + ): TServerRouteRepository extends ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions, + infer TRouteStateToMerge + > + ? ServerRouteRepository< + TRouteHandlerResources, + TRouteCreateOptions, + TRouteState & TRouteStateToMerge + > + : never; + getRoutes: () => Array< + ServerRoute + >; +} + +type ClientRequestParamsOfType< + TRouteParamsRT extends RouteParamsRT +> = TRouteParamsRT extends t.Mixed + ? MaybeOptional<{ + params: t.OutputOf; + }> + : {}; + +type DecodedRequestParamsOfType< + TRouteParamsRT extends RouteParamsRT +> = TRouteParamsRT extends t.Mixed + ? MaybeOptional<{ + params: t.TypeOf; + }> + : {}; + +export type EndpointOf< + TServerRouteRepository extends ServerRouteRepository +> = TServerRouteRepository extends ServerRouteRepository + ? keyof TRouteState + : never; + +export type ReturnOf< + TServerRouteRepository extends ServerRouteRepository, + TEndpoint extends EndpointOf +> = TServerRouteRepository extends ServerRouteRepository + ? TEndpoint extends keyof TRouteState + ? TRouteState[TEndpoint] extends ServerRoute< + any, + any, + any, + infer TReturnType, + ServerRouteCreateOptions + > + ? TReturnType + : never + : never + : never; + +export type DecodedRequestParamsOf< + TServerRouteRepository extends ServerRouteRepository, + TEndpoint extends EndpointOf +> = TServerRouteRepository extends ServerRouteRepository + ? TEndpoint extends keyof TRouteState + ? TRouteState[TEndpoint] extends ServerRoute< + any, + infer TRouteParamsRT, + any, + any, + ServerRouteCreateOptions + > + ? TRouteParamsRT extends RouteParamsRT + ? DecodedRequestParamsOfType + : {} + : never + : never + : never; + +export type ClientRequestParamsOf< + TServerRouteRepository extends ServerRouteRepository, + TEndpoint extends EndpointOf +> = TServerRouteRepository extends ServerRouteRepository + ? TEndpoint extends keyof TRouteState + ? TRouteState[TEndpoint] extends ServerRoute< + any, + infer TRouteParamsRT, + any, + any, + ServerRouteCreateOptions + > + ? TRouteParamsRT extends RouteParamsRT + ? ClientRequestParamsOfType + : {} + : never + : never + : never; + +export type RouteRepositoryClient< + TServerRouteRepository extends ServerRouteRepository, + TAdditionalClientOptions extends Record +> = >( + options: { + endpoint: TEndpoint; + } & ClientRequestParamsOf & + TAdditionalClientOptions +) => Promise>; diff --git a/packages/kbn-server-route-repository/tsconfig.json b/packages/kbn-server-route-repository/tsconfig.json new file mode 100644 index 0000000000000..8f1e72172c675 --- /dev/null +++ b/packages/kbn-server-route-repository/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "incremental": false, + "outDir": "./target", + "stripInternal": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-server-route-repository/src", + "types": [ + "jest", + "node" + ], + "noUnusedLocals": false + }, + "include": [ + "./src/**/*.ts" + ] +} diff --git a/x-pack/plugins/apm/common/latency_aggregation_types.ts b/x-pack/plugins/apm/common/latency_aggregation_types.ts index d9db58f223144..964d6f4ed1015 100644 --- a/x-pack/plugins/apm/common/latency_aggregation_types.ts +++ b/x-pack/plugins/apm/common/latency_aggregation_types.ts @@ -14,7 +14,7 @@ export enum LatencyAggregationType { } export const latencyAggregationTypeRt = t.union([ - t.literal('avg'), - t.literal('p95'), - t.literal('p99'), + t.literal(LatencyAggregationType.avg), + t.literal(LatencyAggregationType.p95), + t.literal(LatencyAggregationType.p99), ]); diff --git a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts index 1a17f82a52141..970e39bc4f86f 100644 --- a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts +++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts @@ -21,8 +21,5 @@ export const isoToEpochRt = new t.Type( ? t.failure(input, context) : t.success(epochDate); }), - (a) => { - const d = new Date(a); - return d.toISOString(); - } + (output) => new Date(output).toISOString() ); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx index 6a6db40892e10..407f460f25ad3 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx @@ -14,15 +14,18 @@ import { act, waitFor, } from '@testing-library/react'; -import * as apmApi from '../../../../../../services/rest/createCallApmApi'; +import { + getCallApmApiSpy, + CallApmApiSpy, +} from '../../../../../../services/rest/callApmApiSpy'; export const removeExternalLinkText = (str: string) => str.replace(/\(opens in a new tab or window\)/g, ''); describe('LinkPreview', () => { - let callApmApiSpy: jest.SpyInstance; + let callApmApiSpy: CallApmApiSpy; beforeAll(() => { - callApmApiSpy = jest.spyOn(apmApi, 'callApmApi').mockResolvedValue({ + callApmApiSpy = getCallApmApiSpy().mockResolvedValue({ transaction: { id: 'foo' }, }); }); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 77835afef863a..7d119b8c406da 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -8,6 +8,7 @@ import { fireEvent, render, RenderResult } from '@testing-library/react'; import React from 'react'; import { act } from 'react-dom/test-utils'; +import { getCallApmApiSpy } from '../../../../../services/rest/callApmApiSpy'; import { CustomLinkOverview } from '.'; import { License } from '../../../../../../../licensing/common/license'; import { ApmPluginContextValue } from '../../../../../context/apm_plugin/apm_plugin_context'; @@ -17,7 +18,6 @@ import { } from '../../../../../context/apm_plugin/mock_apm_plugin_context'; import { LicenseContext } from '../../../../../context/license/license_context'; import * as hooks from '../../../../../hooks/use_fetcher'; -import * as apmApi from '../../../../../services/rest/createCallApmApi'; import { expectTextsInDocument, expectTextsNotInDocument, @@ -43,7 +43,7 @@ function getMockAPMContext({ canSave }: { canSave: boolean }) { describe('CustomLink', () => { beforeAll(() => { - jest.spyOn(apmApi, 'callApmApi').mockResolvedValue({}); + getCallApmApiSpy().mockResolvedValue({}); }); afterAll(() => { jest.resetAllMocks(); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index b30faac7a65af..c6ed4e640693f 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -22,9 +22,12 @@ import * as useTransactionBreakdownHooks from '../../shared/charts/transaction_b import { renderWithTheme } from '../../../utils/testHelpers'; import { ServiceOverview } from './'; import { waitFor } from '@testing-library/dom'; -import * as callApmApiModule from '../../../services/rest/createCallApmApi'; import * as useApmServiceContextHooks from '../../../context/apm_service/use_apm_service_context'; import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; +import { + getCallApmApiSpy, + getCreateCallApmApiSpy, +} from '../../../services/rest/callApmApiSpy'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiCounter: () => {} }, @@ -83,10 +86,10 @@ describe('ServiceOverview', () => { /* eslint-disable @typescript-eslint/naming-convention */ const calls = { 'GET /api/apm/services/{serviceName}/error_groups/primary_statistics': { - error_groups: [], + error_groups: [] as any[], }, 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics': { - transactionGroups: [], + transactionGroups: [] as any[], totalTransactionGroups: 0, isAggregationAccurate: true, }, @@ -95,19 +98,17 @@ describe('ServiceOverview', () => { }; /* eslint-enable @typescript-eslint/naming-convention */ - jest - .spyOn(callApmApiModule, 'createCallApmApi') - .mockImplementation(() => {}); - - const callApmApi = jest - .spyOn(callApmApiModule, 'callApmApi') - .mockImplementation(({ endpoint }) => { + const callApmApiSpy = getCallApmApiSpy().mockImplementation( + ({ endpoint }) => { const response = calls[endpoint as keyof typeof calls]; return response ? Promise.resolve(response) : Promise.reject(`Response for ${endpoint} is not defined`); - }); + } + ); + + getCreateCallApmApiSpy().mockImplementation(() => callApmApiSpy as any); jest .spyOn(useTransactionBreakdownHooks, 'useTransactionBreakdown') .mockReturnValue({ @@ -124,7 +125,7 @@ describe('ServiceOverview', () => { ); await waitFor(() => - expect(callApmApi).toHaveBeenCalledTimes(Object.keys(calls).length) + expect(callApmApiSpy).toHaveBeenCalledTimes(Object.keys(calls).length) ); expect((await findAllByText('Latency')).length).toBeGreaterThan(0); diff --git a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts index 29fabc51fd582..00447607cf787 100644 --- a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts @@ -10,10 +10,10 @@ import { fetchObservabilityOverviewPageData, getHasData, } from './apm_observability_overview_fetchers'; -import * as createCallApmApi from './createCallApmApi'; +import { getCallApmApiSpy } from './callApmApiSpy'; describe('Observability dashboard data', () => { - const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi'); + const callApmApiMock = getCallApmApiSpy(); const params = { absoluteTime: { start: moment('2020-07-02T13:25:11.629Z').valueOf(), @@ -84,7 +84,7 @@ describe('Observability dashboard data', () => { callApmApiMock.mockImplementation(() => Promise.resolve({ serviceCount: 0, - transactionPerMinute: { value: null, timeseries: [] }, + transactionPerMinute: { value: null, timeseries: [] as any }, }) ); const response = await fetchObservabilityOverviewPageData(params); diff --git a/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts b/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts new file mode 100644 index 0000000000000..ba9f740e06d0d --- /dev/null +++ b/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as createCallApmApi from './createCallApmApi'; +import type { AbstractAPMClient } from './createCallApmApi'; + +export type CallApmApiSpy = jest.SpyInstance< + Promise, + Parameters +>; + +export type CreateCallApmApiSpy = jest.SpyInstance; + +export const getCreateCallApmApiSpy = () => + (jest.spyOn( + createCallApmApi, + 'createCallApmApi' + ) as unknown) as CreateCallApmApiSpy; +export const getCallApmApiSpy = () => + (jest.spyOn(createCallApmApi, 'callApmApi') as unknown) as CallApmApiSpy; diff --git a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts index b0cce3296fe21..0e82d70faf1e1 100644 --- a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts @@ -6,30 +6,68 @@ */ import { CoreSetup, CoreStart } from 'kibana/public'; -import { parseEndpoint } from '../../../common/apm_api/parse_endpoint'; +import * as t from 'io-ts'; +import type { + ClientRequestParamsOf, + EndpointOf, + ReturnOf, + RouteRepositoryClient, + ServerRouteRepository, + ServerRoute, +} from '@kbn/server-route-repository'; +import { formatRequest } from '@kbn/server-route-repository/target/format_request'; import { FetchOptions } from '../../../common/fetch_options'; import { callApi } from './callApi'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import type { APMAPI } from '../../../server/routes/create_apm_api'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import type { Client } from '../../../server/routes/typings'; - -export type APMClient = Client; -export type AutoAbortedAPMClient = Client; +import type { + APMServerRouteRepository, + InspectResponse, + APMRouteHandlerResources, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../server'; export type APMClientOptions = Omit< FetchOptions, 'query' | 'body' | 'pathname' | 'signal' > & { - endpoint: string; signal: AbortSignal | null; - params?: { - body?: any; - query?: Record; - path?: Record; - }; }; +export type APMClient = RouteRepositoryClient< + APMServerRouteRepository, + APMClientOptions +>; + +export type AutoAbortedAPMClient = RouteRepositoryClient< + APMServerRouteRepository, + Omit +>; + +export type APIReturnType< + TEndpoint extends EndpointOf +> = ReturnOf & { + _inspect?: InspectResponse; +}; + +export type APIEndpoint = EndpointOf; + +export type APIClientRequestParamsOf< + TEndpoint extends EndpointOf +> = ClientRequestParamsOf; + +export type AbstractAPMRepository = ServerRouteRepository< + APMRouteHandlerResources, + {}, + Record< + string, + ServerRoute + > +>; + +export type AbstractAPMClient = RouteRepositoryClient< + AbstractAPMRepository, + APMClientOptions +>; + export let callApmApi: APMClient = () => { throw new Error( 'callApmApi has to be initialized before used. Call createCallApmApi first.' @@ -37,9 +75,13 @@ export let callApmApi: APMClient = () => { }; export function createCallApmApi(core: CoreStart | CoreSetup) { - callApmApi = ((options: APMClientOptions) => { - const { endpoint, params, ...opts } = options; - const { method, pathname } = parseEndpoint(endpoint, params?.path); + callApmApi = ((options) => { + const { endpoint, ...opts } = options; + const { params } = (options as unknown) as { + params?: Partial>; + }; + + const { method, pathname } = formatRequest(endpoint, params?.path); return callApi(core, { ...opts, @@ -50,10 +92,3 @@ export function createCallApmApi(core: CoreStart | CoreSetup) { }); }) as APMClient; } - -// infer return type from API -export type APIReturnType< - TPath extends keyof APMAPI['_S'] -> = APMAPI['_S'][TPath] extends { ret: any } - ? APMAPI['_S'][TPath]['ret'] - : unknown; diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 00910353ac278..9ab56c1a303ea 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -120,5 +120,9 @@ export function mergeConfigs( export const plugin = (initContext: PluginInitializerContext) => new APMPlugin(initContext); -export { APMPlugin, APMPluginSetup } from './plugin'; +export { APMPlugin } from './plugin'; +export { APMPluginSetup } from './types'; +export { APMServerRouteRepository } from './routes/get_global_apm_server_route_repository'; +export { InspectResponse, APMRouteHandlerResources } from './routes/typings'; + export type { ProcessorEvent } from '../common/processor_event'; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts index 1f0aa401bcab0..989297544c78f 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts @@ -10,7 +10,7 @@ import { omit } from 'lodash'; import chalk from 'chalk'; import { KibanaRequest } from '../../../../../../../src/core/server'; -import { inspectableEsQueriesMap } from '../../../routes/create_api'; +import { inspectableEsQueriesMap } from '../../../routes/register_routes'; function formatObj(obj: Record) { return JSON.stringify(obj, null, 2); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts index 45e17c1678518..9d7434d127ead 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { KibanaRequest } from 'src/core/server'; import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { CreateIndexRequest, @@ -13,7 +12,7 @@ import { IndexRequest, } from '@elastic/elasticsearch/api/types'; import { unwrapEsResponse } from '../../../../../../observability/server'; -import { APMRequestHandlerContext } from '../../../../routes/typings'; +import { APMRouteHandlerResources } from '../../../../routes/typings'; import { ESSearchResponse, ESSearchRequest, @@ -31,11 +30,9 @@ export type APMInternalClient = ReturnType; export function createInternalESClient({ context, + debug, request, -}: { - context: APMRequestHandlerContext; - request: KibanaRequest; -}) { +}: Pick & { debug: boolean }) { const { asInternalUser } = context.core.elasticsearch.client; function callEs({ @@ -53,7 +50,7 @@ export function createInternalESClient({ title: getDebugTitle(request), body: getDebugBody(params, requestType), }), - debug: context.params.query._inspect, + debug, isCalledWithInternalUser: true, request, requestType, diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index c0707d0286180..c0ff0cab88f47 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -7,8 +7,7 @@ import { setupRequest } from './setup_request'; import { APMConfig } from '../..'; -import { APMRequestHandlerContext } from '../../routes/typings'; -import { KibanaRequest } from '../../../../../../src/core/server'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { ProcessorEvent } from '../../../common/processor_event'; import { PROCESSOR_EVENT } from '../../../common/elasticsearch_fieldnames'; @@ -32,7 +31,7 @@ jest.mock('../index_pattern/get_dynamic_index_pattern', () => ({ }, })); -function getMockRequest() { +function getMockResources() { const esClientMock = { asCurrentUser: { search: jest.fn().mockResolvedValue({ body: {} }), @@ -42,7 +41,7 @@ function getMockRequest() { }, }; - const mockContext = ({ + const mockResources = ({ config: new Proxy( {}, { @@ -54,65 +53,69 @@ function getMockRequest() { _inspect: false, }, }, - core: { - elasticsearch: { - client: esClientMock, - }, - uiSettings: { - client: { - get: jest.fn().mockResolvedValue(false), + context: { + core: { + elasticsearch: { + client: esClientMock, }, - }, - savedObjects: { - client: { - get: jest.fn(), + uiSettings: { + client: { + get: jest.fn().mockResolvedValue(false), + }, + }, + savedObjects: { + client: { + get: jest.fn(), + }, }, }, }, plugins: { ml: undefined, }, - } as unknown) as APMRequestHandlerContext & { - core: { - elasticsearch: { - client: typeof esClientMock; - }; - uiSettings: { - client: { - get: jest.Mock; + request: { + url: '', + events: { + aborted$: { + subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), + }, + }, + }, + } as unknown) as APMRouteHandlerResources & { + context: { + core: { + elasticsearch: { + client: typeof esClientMock; }; - }; - savedObjects: { - client: { - get: jest.Mock; + uiSettings: { + client: { + get: jest.Mock; + }; + }; + savedObjects: { + client: { + get: jest.Mock; + }; }; }; }; }; - const mockRequest = ({ - url: '', - events: { - aborted$: { - subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), - }, - }, - } as unknown) as KibanaRequest; - - return { mockContext, mockRequest }; + return mockResources; } describe('setupRequest', () => { describe('with default args', () => { it('calls callWithRequest', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [ProcessorEvent.transaction] }, body: { foo: 'bar' }, }); + expect( - mockContext.core.elasticsearch.client.asCurrentUser.search + mockResources.context.core.elasticsearch.client.asCurrentUser.search ).toHaveBeenCalledWith({ index: ['apm-*'], body: { @@ -132,14 +135,14 @@ describe('setupRequest', () => { }); it('calls callWithInternalUser', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { internalClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { internalClient } = await setupRequest(mockResources); await internalClient.search({ index: ['apm-*'], body: { foo: 'bar' }, } as any); expect( - mockContext.core.elasticsearch.client.asInternalUser.search + mockResources.context.core.elasticsearch.client.asInternalUser.search ).toHaveBeenCalledWith({ index: ['apm-*'], body: { @@ -151,8 +154,8 @@ describe('setupRequest', () => { describe('with a bool filter', () => { it('adds a range filter for `observer.version_major` to the existing filter', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [ProcessorEvent.transaction], @@ -162,8 +165,8 @@ describe('setupRequest', () => { }, }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock - .calls[0][0]; + mockResources.context.core.elasticsearch.client.asCurrentUser.search + .mock.calls[0][0]; expect(params.body).toEqual({ query: { bool: { @@ -178,8 +181,8 @@ describe('setupRequest', () => { }); it('does not add a range filter for `observer.version_major` if includeLegacyData=true', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search( { apm: { @@ -194,8 +197,8 @@ describe('setupRequest', () => { } ); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock - .calls[0][0]; + mockResources.context.core.elasticsearch.client.asCurrentUser.search + .mock.calls[0][0]; expect(params.body).toEqual({ query: { bool: { @@ -216,15 +219,15 @@ describe('setupRequest', () => { describe('without a bool filter', () => { it('adds a range filter for `observer.version_major`', async () => { - const { mockContext, mockRequest } = getMockRequest(); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const mockResources = getMockResources(); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [ProcessorEvent.error], }, }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock + mockResources.context.core.elasticsearch.client.asCurrentUser.search.mock .calls[0][0]; expect(params.body).toEqual({ query: { @@ -241,12 +244,12 @@ describe('without a bool filter', () => { describe('with includeFrozen=false', () => { it('sets `ignore_throttled=true`', async () => { - const { mockContext, mockRequest } = getMockRequest(); + const mockResources = getMockResources(); // mock includeFrozen to return false - mockContext.core.uiSettings.client.get.mockResolvedValue(false); + mockResources.context.core.uiSettings.client.get.mockResolvedValue(false); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { @@ -255,7 +258,7 @@ describe('with includeFrozen=false', () => { }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock + mockResources.context.core.elasticsearch.client.asCurrentUser.search.mock .calls[0][0]; expect(params.ignore_throttled).toBe(true); }); @@ -263,19 +266,19 @@ describe('with includeFrozen=false', () => { describe('with includeFrozen=true', () => { it('sets `ignore_throttled=false`', async () => { - const { mockContext, mockRequest } = getMockRequest(); + const mockResources = getMockResources(); // mock includeFrozen to return true - mockContext.core.uiSettings.client.get.mockResolvedValue(true); + mockResources.context.core.uiSettings.client.get.mockResolvedValue(true); - const { apmEventClient } = await setupRequest(mockContext, mockRequest); + const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search({ apm: { events: [] }, }); const params = - mockContext.core.elasticsearch.client.asCurrentUser.search.mock + mockResources.context.core.elasticsearch.client.asCurrentUser.search.mock .calls[0][0]; expect(params.ignore_throttled).toBe(false); }); diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index fff661250c6df..40836cb6635e3 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -11,7 +11,7 @@ import { APMConfig } from '../..'; import { KibanaRequest } from '../../../../../../src/core/server'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; import { UIFilters } from '../../../typings/ui_filters'; -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { ApmIndicesConfig, getApmIndices, @@ -44,7 +44,7 @@ export interface SetupTimeRange { } interface SetupRequestParams { - query?: { + query: { _inspect?: boolean; /** @@ -64,13 +64,19 @@ type InferSetup = Setup & (TParams extends { query: { start: number } } ? { start: number } : {}) & (TParams extends { query: { end: number } } ? { end: number } : {}); -export async function setupRequest( - context: APMRequestHandlerContext, - request: KibanaRequest -): Promise> { +export async function setupRequest({ + context, + params, + core, + plugins, + request, + config, + logger, +}: APMRouteHandlerResources & { + params: TParams; +}): Promise> { return withApmSpan('setup_request', async () => { - const { config, logger } = context; - const { query } = context.params; + const { query } = params; const [indices, includeFrozen] = await Promise.all([ getApmIndices({ @@ -88,7 +94,7 @@ export async function setupRequest( indices, apmEventClient: createApmEventClient({ esClient: context.core.elasticsearch.client.asCurrentUser, - debug: context.params.query._inspect, + debug: query._inspect, request, indices, options: { includeFrozen }, @@ -96,11 +102,12 @@ export async function setupRequest( internalClient: createInternalESClient({ context, request, + debug: query._inspect, }), ml: - context.plugins.ml && isActivePlatinumLicense(context.licensing.license) + plugins.ml && isActivePlatinumLicense(context.licensing.license) ? getMlSetup( - context.plugins.ml, + plugins.ml.setup, context.core.savedObjects.client, request ) @@ -118,8 +125,8 @@ export async function setupRequest( } function getMlSetup( - ml: Required['ml'], - savedObjectsClient: APMRequestHandlerContext['core']['savedObjects']['client'], + ml: Required['ml']['setup'], + savedObjectsClient: APMRouteHandlerResources['context']['core']['savedObjects']['client'], request: KibanaRequest ) { return { diff --git a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts index 19163da449b90..a5340c1220b44 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.test.ts @@ -8,21 +8,9 @@ import { createStaticIndexPattern } from './create_static_index_pattern'; import { Setup } from '../helpers/setup_request'; import * as HistoricalAgentData from '../services/get_services/has_historical_agent_data'; -import { APMRequestHandlerContext } from '../../routes/typings'; import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; +import { APMConfig } from '../..'; -function getMockContext(config: Record) { - return ({ - config, - core: { - savedObjects: { - client: { - create: jest.fn(), - }, - }, - }, - } as unknown) as APMRequestHandlerContext; -} function getMockSavedObjectsClient() { return ({ create: jest.fn(), @@ -32,13 +20,13 @@ function getMockSavedObjectsClient() { describe('createStaticIndexPattern', () => { it(`should not create index pattern if 'xpack.apm.autocreateApmIndexPattern=false'`, async () => { const setup = {} as Setup; - const context = getMockContext({ - 'xpack.apm.autocreateApmIndexPattern': false, - }); + const savedObjectsClient = getMockSavedObjectsClient(); await createStaticIndexPattern( setup, - context, + { + 'xpack.apm.autocreateApmIndexPattern': false, + } as APMConfig, savedObjectsClient, 'default' ); @@ -47,9 +35,6 @@ describe('createStaticIndexPattern', () => { it(`should not create index pattern if no APM data is found`, async () => { const setup = {} as Setup; - const context = getMockContext({ - 'xpack.apm.autocreateApmIndexPattern': true, - }); // does not have APM data jest @@ -60,7 +45,9 @@ describe('createStaticIndexPattern', () => { await createStaticIndexPattern( setup, - context, + { + 'xpack.apm.autocreateApmIndexPattern': true, + } as APMConfig, savedObjectsClient, 'default' ); @@ -69,9 +56,6 @@ describe('createStaticIndexPattern', () => { it(`should create index pattern`, async () => { const setup = {} as Setup; - const context = getMockContext({ - 'xpack.apm.autocreateApmIndexPattern': true, - }); // does have APM data jest @@ -82,7 +66,9 @@ describe('createStaticIndexPattern', () => { await createStaticIndexPattern( setup, - context, + { + 'xpack.apm.autocreateApmIndexPattern': true, + } as APMConfig, savedObjectsClient, 'default' ); 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 b91fb8342a212..e627e9ed1d6cf 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 @@ -12,20 +12,18 @@ import { } from '../../../../../../src/plugins/apm_oss/server'; import { hasHistoricalAgentData } from '../services/get_services/has_historical_agent_data'; import { Setup } from '../helpers/setup_request'; -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client.js'; import { withApmSpan } from '../../utils/with_apm_span'; import { getApmIndexPatternTitle } from './get_apm_index_pattern_title'; export async function createStaticIndexPattern( setup: Setup, - context: APMRequestHandlerContext, + config: APMRouteHandlerResources['config'], savedObjectsClient: InternalSavedObjectsClient, spaceId: string | undefined ): Promise { return withApmSpan('create_static_index_pattern', async () => { - const { config } = context; - // don't autocreate APM index pattern if it's been disabled via the config if (!config['xpack.apm.autocreateApmIndexPattern']) { return false; @@ -39,7 +37,7 @@ export async function createStaticIndexPattern( } try { - const apmIndexPatternTitle = getApmIndexPatternTitle(context); + const apmIndexPatternTitle = getApmIndexPatternTitle(config); await withApmSpan('create_index_pattern_saved_object', () => savedObjectsClient.create( 'index-pattern', diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts index 41abe82de8ff2..faec64c798c7d 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; -export function getApmIndexPatternTitle(context: APMRequestHandlerContext) { - return context.config['apm_oss.indexPattern']; +export function getApmIndexPatternTitle( + config: APMRouteHandlerResources['config'] +) { + return config['apm_oss.indexPattern']; } 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 5d5e6eebb4c9f..8bbc22fbf289d 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 @@ -9,7 +9,7 @@ import { IndexPatternsFetcher, FieldDescriptor, } from '../../../../../../src/plugins/data/server'; -import { APMRequestHandlerContext } from '../../routes/typings'; +import { APMRouteHandlerResources } from '../../routes/typings'; import { withApmSpan } from '../../utils/with_apm_span'; export interface IndexPatternTitleAndFields { @@ -20,12 +20,12 @@ export interface IndexPatternTitleAndFields { // TODO: this is currently cached globally. In the future we might want to cache this per user export const getDynamicIndexPattern = ({ + config, context, -}: { - context: APMRequestHandlerContext; -}) => { + logger, +}: Pick) => { return withApmSpan('get_dynamic_index_pattern', async () => { - const indexPatternTitle = context.config['apm_oss.indexPattern']; + const indexPatternTitle = config['apm_oss.indexPattern']; const indexPatternsFetcher = new IndexPatternsFetcher( context.core.elasticsearch.client.asCurrentUser @@ -50,7 +50,7 @@ export const getDynamicIndexPattern = ({ } catch (e) { const notExists = e.output?.statusCode === 404; if (notExists) { - context.logger.error( + logger.error( `Could not get dynamic index pattern because indices "${indexPatternTitle}" don't exist` ); return; diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts index a1587611b0a2a..d8dbc242986a6 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts @@ -14,7 +14,7 @@ import { APM_INDICES_SAVED_OBJECT_ID, } from '../../../../common/apm_saved_object_constants'; import { APMConfig } from '../../..'; -import { APMRequestHandlerContext } from '../../../routes/typings'; +import { APMRouteHandlerResources } from '../../../routes/typings'; import { withApmSpan } from '../../../utils/with_apm_span'; type ISavedObjectsClient = Pick; @@ -91,9 +91,8 @@ const APM_UI_INDICES: ApmIndicesName[] = [ export async function getApmIndexSettings({ context, -}: { - context: APMRequestHandlerContext; -}) { + config, +}: Pick) { let apmIndicesSavedObject: PromiseReturnType; try { apmIndicesSavedObject = await getApmIndicesSavedObject( @@ -106,7 +105,7 @@ export async function getApmIndexSettings({ throw error; } } - const apmIndicesConfig = getApmIndicesConfig(context.config); + const apmIndicesConfig = getApmIndicesConfig(config); return APM_UI_INDICES.map((configurationName) => ({ configurationName, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index db96794627519..074df7eaafd3c 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { combineLatest, Observable } from 'rxjs'; +import { combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { CoreSetup, @@ -16,22 +16,10 @@ import { Plugin, PluginInitializerContext, } from 'src/core/server'; -import { SpacesPluginSetup } from '../../spaces/server'; +import { mapValues } from 'lodash'; 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'; import { UI_SETTINGS } from '../../../../src/plugins/data/common'; -import { ActionsPlugin } from '../../actions/server'; -import { AlertingPlugin } from '../../alerting/server'; -import { CloudSetup } from '../../cloud/server'; -import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; -import { LicensingPluginSetup } from '../../licensing/server'; -import { MlPluginSetup } from '../../ml/server'; -import { ObservabilityPluginSetup } from '../../observability/server'; -import { SecurityPluginSetup } from '../../security/server'; -import { TaskManagerSetupContract } from '../../task_manager/server'; import { APM_FEATURE, registerFeaturesUsage } from './feature'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; @@ -40,23 +28,29 @@ import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_ import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; -import { createApmApi } from './routes/create_apm_api'; import { apmIndices, apmTelemetry } from './saved_objects'; import { createElasticCloudInstructions } from './tutorial/elastic_cloud'; import { uiSettings } from './ui_settings'; -import type { ApmPluginRequestHandlerContext } from './routes/typings'; - -export interface APMPluginSetup { - config$: Observable; - getApmIndices: () => ReturnType; - createApmEventClient: (params: { - debug?: boolean; - request: KibanaRequest; - context: ApmPluginRequestHandlerContext; - }) => Promise>; -} - -export class APMPlugin implements Plugin { +import type { + ApmPluginRequestHandlerContext, + APMRouteHandlerResources, +} from './routes/typings'; +import { + APMPluginSetup, + APMPluginSetupDependencies, + APMPluginStartDependencies, +} from './types'; +import { registerRoutes } from './routes/register_routes'; +import { getGlobalApmServerRouteRepository } from './routes/get_global_apm_server_route_repository'; + +export class APMPlugin + implements + Plugin< + APMPluginSetup, + void, + APMPluginSetupDependencies, + APMPluginStartDependencies + > { private currentConfig?: APMConfig; private logger?: Logger; constructor(private readonly initContext: PluginInitializerContext) { @@ -64,22 +58,8 @@ export class APMPlugin implements Plugin { } public setup( - core: CoreSetup, - plugins: { - spaces?: SpacesPluginSetup; - apmOss: APMOSSPluginSetup; - home: HomeServerPluginSetup; - licensing: LicensingPluginSetup; - cloud?: CloudSetup; - usageCollection?: UsageCollectionSetup; - taskManager?: TaskManagerSetupContract; - alerting?: AlertingPlugin['setup']; - actions?: ActionsPlugin['setup']; - observability?: ObservabilityPluginSetup; - features: FeaturesPluginSetup; - security?: SecurityPluginSetup; - ml?: MlPluginSetup; - } + core: CoreSetup, + plugins: Omit ) { this.logger = this.initContext.logger.get(); const config$ = this.initContext.config.create(); @@ -101,11 +81,13 @@ export class APMPlugin implements Plugin { }); } - this.currentConfig = mergeConfigs( + const currentConfig = mergeConfigs( plugins.apmOss.config, this.initContext.config.get() ); + this.currentConfig = currentConfig; + if ( plugins.taskManager && plugins.usageCollection && @@ -122,8 +104,8 @@ export class APMPlugin implements Plugin { } const ossTutorialProvider = plugins.apmOss.getRegisteredTutorialProvider(); - plugins.home.tutorials.unregisterTutorial(ossTutorialProvider); - plugins.home.tutorials.registerTutorial(() => { + plugins.home?.tutorials.unregisterTutorial(ossTutorialProvider); + plugins.home?.tutorials.registerTutorial(() => { const ossPart = ossTutorialProvider({}); if (this.currentConfig!['xpack.apm.ui.enabled'] && ossPart.artifacts) { ossPart.artifacts.application = { @@ -147,10 +129,26 @@ export class APMPlugin implements Plugin { registerFeaturesUsage({ licensingPlugin: plugins.licensing }); - createApmApi().init(core, { - config$: mergedConfig$, - logger: this.logger!, - plugins, + registerRoutes({ + core: { + setup: core, + start: () => core.getStartServices().then(([coreStart]) => coreStart), + }, + logger: this.logger, + config: currentConfig, + repository: getGlobalApmServerRouteRepository(), + plugins: mapValues(plugins, (value, key) => { + return { + setup: value, + start: () => + core.getStartServices().then((services) => { + const [, pluginsStartContracts] = services; + return pluginsStartContracts[ + key as keyof APMPluginStartDependencies + ]; + }), + }; + }) as APMRouteHandlerResources['plugins'], }); const boundGetApmIndices = async () => diff --git a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts index 3bebcd49ec34a..0175860e93d35 100644 --- a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts +++ b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts @@ -10,7 +10,8 @@ import { getTransactionDurationChartPreview } from '../../lib/alerts/chart_previ import { getTransactionErrorCountChartPreview } from '../../lib/alerts/chart_preview/get_transaction_error_count'; import { getTransactionErrorRateChartPreview } from '../../lib/alerts/chart_preview/get_transaction_error_rate'; import { setupRequest } from '../../lib/helpers/setup_request'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; import { rangeRt } from '../default_api_types'; const alertParamsRt = t.intersection([ @@ -29,13 +30,14 @@ const alertParamsRt = t.intersection([ export type AlertParams = t.TypeOf; -export const transactionErrorRateChartPreview = createRoute({ +const transactionErrorRateChartPreview = createApmServerRoute({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_rate', params: t.type({ query: alertParamsRt }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _inspect, ...alertParams } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { _inspect, ...alertParams } = params.query; const errorRateChartPreview = await getTransactionErrorRateChartPreview({ setup, @@ -46,13 +48,16 @@ export const transactionErrorRateChartPreview = createRoute({ }, }); -export const transactionErrorCountChartPreview = createRoute({ +const transactionErrorCountChartPreview = createApmServerRoute({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_count', params: t.type({ query: alertParamsRt }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _inspect, ...alertParams } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { _inspect, ...alertParams } = params.query; + const errorCountChartPreview = await getTransactionErrorCountChartPreview({ setup, alertParams, @@ -62,13 +67,16 @@ export const transactionErrorCountChartPreview = createRoute({ }, }); -export const transactionDurationChartPreview = createRoute({ +const transactionDurationChartPreview = createApmServerRoute({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_duration', params: t.type({ query: alertParamsRt }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _inspect, ...alertParams } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { params } = resources; + + const { _inspect, ...alertParams } = params.query; const latencyChartPreview = await getTransactionDurationChartPreview({ alertParams, @@ -78,3 +86,9 @@ export const transactionDurationChartPreview = createRoute({ return { latencyChartPreview }; }, }); + +export const alertsChartPreviewRouteRepository = createApmServerRouteRepository() + .add(transactionErrorRateChartPreview) + .add(transactionDurationChartPreview) + .add(transactionErrorCountChartPreview) + .add(transactionDurationChartPreview); diff --git a/x-pack/plugins/apm/server/routes/correlations.ts b/x-pack/plugins/apm/server/routes/correlations.ts index c7c69e0774822..4728aa2e8d3f6 100644 --- a/x-pack/plugins/apm/server/routes/correlations.ts +++ b/x-pack/plugins/apm/server/routes/correlations.ts @@ -14,7 +14,8 @@ import { getOverallErrorTimeseries } from '../lib/correlations/errors/get_overal import { getCorrelationsForSlowTransactions } from '../lib/correlations/latency/get_correlations_for_slow_transactions'; import { getOverallLatencyDistribution } from '../lib/correlations/latency/get_overall_latency_distribution'; import { setupRequest } from '../lib/helpers/setup_request'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; const INVALID_LICENSE = i18n.translate( @@ -25,7 +26,7 @@ const INVALID_LICENSE = i18n.translate( } ); -export const correlationsLatencyDistributionRoute = createRoute({ +const correlationsLatencyDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/latency/overall_distribution', params: t.type({ query: t.intersection([ @@ -40,18 +41,19 @@ export const correlationsLatencyDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, serviceName, transactionType, transactionName, - } = context.params.query; + } = params.query; return getOverallLatencyDistribution({ environment, @@ -64,7 +66,7 @@ export const correlationsLatencyDistributionRoute = createRoute({ }, }); -export const correlationsForSlowTransactionsRoute = createRoute({ +const correlationsForSlowTransactionsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/latency/slow_transactions', params: t.type({ query: t.intersection([ @@ -85,11 +87,13 @@ export const correlationsForSlowTransactionsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; + if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, @@ -100,7 +104,7 @@ export const correlationsForSlowTransactionsRoute = createRoute({ fieldNames, maxLatency, distributionInterval, - } = context.params.query; + } = params.query; return getCorrelationsForSlowTransactions({ environment, @@ -117,7 +121,7 @@ export const correlationsForSlowTransactionsRoute = createRoute({ }, }); -export const correlationsErrorDistributionRoute = createRoute({ +const correlationsErrorDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/errors/overall_timeseries', params: t.type({ query: t.intersection([ @@ -132,18 +136,20 @@ export const correlationsErrorDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { params, context } = resources; + if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, serviceName, transactionType, transactionName, - } = context.params.query; + } = params.query; return getOverallErrorTimeseries({ environment, @@ -156,7 +162,7 @@ export const correlationsErrorDistributionRoute = createRoute({ }, }); -export const correlationsForFailedTransactionsRoute = createRoute({ +const correlationsForFailedTransactionsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/correlations/errors/failed_transactions', params: t.type({ query: t.intersection([ @@ -174,11 +180,12 @@ export const correlationsForFailedTransactionsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { environment, kuery, @@ -186,7 +193,7 @@ export const correlationsForFailedTransactionsRoute = createRoute({ transactionType, transactionName, fieldNames, - } = context.params.query; + } = params.query; return getCorrelationsForFailedTransactions({ environment, @@ -199,3 +206,9 @@ export const correlationsForFailedTransactionsRoute = createRoute({ }); }, }); + +export const correlationsRouteRepository = createApmServerRouteRepository() + .add(correlationsLatencyDistributionRoute) + .add(correlationsForSlowTransactionsRoute) + .add(correlationsErrorDistributionRoute) + .add(correlationsForFailedTransactionsRoute); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts deleted file mode 100644 index 9958b8dec0124..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from 'io-ts'; -import { createApi } from './index'; -import { CoreSetup, Logger } from 'src/core/server'; -import { RouteParamsRT } from '../typings'; -import { BehaviorSubject } from 'rxjs'; -import { APMConfig } from '../..'; -import { jsonRt } from '../../../common/runtime_types/json_rt'; - -const getCoreMock = () => { - const get = jest.fn(); - const post = jest.fn(); - const put = jest.fn(); - const createRouter = jest.fn().mockReturnValue({ - get, - post, - put, - }); - - const mock = {} as CoreSetup; - - return { - mock: { - ...mock, - http: { - ...mock.http, - createRouter, - }, - }, - get, - post, - put, - createRouter, - context: { - measure: () => undefined, - config$: new BehaviorSubject({} as APMConfig), - logger: ({ - error: jest.fn(), - } as unknown) as Logger, - plugins: {}, - }, - }; -}; - -const initApi = (params?: RouteParamsRT) => { - const { mock, context, createRouter, get, post } = getCoreMock(); - const handlerMock = jest.fn(); - createApi() - .add(() => ({ - endpoint: 'GET /foo', - params, - options: { tags: ['access:apm'] }, - handler: handlerMock, - })) - .init(mock, context); - - const routeHandler = get.mock.calls[0][1]; - - const responseMock = { - ok: jest.fn(), - custom: jest.fn(), - }; - - const simulateRequest = (requestMock: any) => { - return routeHandler( - {}, - { - // stub default values - params: {}, - query: {}, - body: null, - ...requestMock, - }, - responseMock - ); - }; - - return { - simulateRequest, - handlerMock, - createRouter, - get, - post, - responseMock, - }; -}; - -describe('createApi', () => { - it('registers a route with the server', () => { - const { mock, context, createRouter, post, get, put } = getCoreMock(); - - createApi() - .add(() => ({ - endpoint: 'GET /foo', - options: { tags: ['access:apm'] }, - handler: async () => ({}), - })) - .add(() => ({ - endpoint: 'POST /bar', - params: t.type({ - body: t.string, - }), - options: { tags: ['access:apm'] }, - handler: async () => ({}), - })) - .add(() => ({ - endpoint: 'PUT /baz', - options: { - tags: ['access:apm', 'access:apm_write'], - }, - handler: async () => ({}), - })) - .add({ - endpoint: 'GET /qux', - options: { - tags: ['access:apm', 'access:apm_write'], - }, - handler: async () => ({}), - }) - .init(mock, context); - - expect(createRouter).toHaveBeenCalledTimes(1); - - expect(get).toHaveBeenCalledTimes(2); - expect(post).toHaveBeenCalledTimes(1); - expect(put).toHaveBeenCalledTimes(1); - - expect(get.mock.calls[0][0]).toEqual({ - options: { - tags: ['access:apm'], - }, - path: '/foo', - validate: expect.anything(), - }); - - expect(get.mock.calls[1][0]).toEqual({ - options: { - tags: ['access:apm', 'access:apm_write'], - }, - path: '/qux', - validate: expect.anything(), - }); - - expect(post.mock.calls[0][0]).toEqual({ - options: { - tags: ['access:apm'], - }, - path: '/bar', - validate: expect.anything(), - }); - - expect(put.mock.calls[0][0]).toEqual({ - options: { - tags: ['access:apm', 'access:apm_write'], - }, - path: '/baz', - validate: expect.anything(), - }); - }); - - describe('when validating', () => { - describe('_inspect', () => { - it('allows _inspect=true', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi(); - await simulateRequest({ query: { _inspect: 'true' } }); - - const params = handlerMock.mock.calls[0][0].context.params; - expect(params).toEqual({ query: { _inspect: true } }); - expect(handlerMock).toHaveBeenCalledTimes(1); - - // responds with ok - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(responseMock.ok).toHaveBeenCalledWith({ - body: { _inspect: [] }, - }); - }); - - it('rejects _inspect=1', async () => { - const { simulateRequest, responseMock } = initApi(); - await simulateRequest({ query: { _inspect: 1 } }); - - // responds with error handler - expect(responseMock.ok).not.toHaveBeenCalled(); - expect(responseMock.custom).toHaveBeenCalledWith({ - body: { - attributes: { _inspect: [] }, - message: - 'Invalid value 1 supplied to : strict_keys/query: Partial<{| _inspect: pipe(JSON, boolean) |}>/_inspect: pipe(JSON, boolean)', - }, - statusCode: 400, - }); - }); - - it('allows omitting _inspect', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi(); - await simulateRequest({ query: {} }); - - const params = handlerMock.mock.calls[0][0].context.params; - expect(params).toEqual({ query: { _inspect: false } }); - expect(handlerMock).toHaveBeenCalledTimes(1); - - // responds with ok - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(responseMock.ok).toHaveBeenCalledWith({ body: {} }); - }); - }); - - it('throws if unknown parameters are provided', async () => { - const { simulateRequest, responseMock } = initApi(); - - await simulateRequest({ - query: { _inspect: true, extra: '' }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - - await simulateRequest({ - body: { foo: 'bar' }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(2); - - await simulateRequest({ - params: { - foo: 'bar', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(3); - }); - - it('validates path parameters', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi( - t.type({ - path: t.type({ - foo: t.string, - }), - }) - ); - - await simulateRequest({ - params: { - foo: 'bar', - }, - }); - - expect(handlerMock).toHaveBeenCalledTimes(1); - - expect(responseMock.ok).toHaveBeenCalledTimes(1); - expect(responseMock.custom).not.toHaveBeenCalled(); - - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - path: { - foo: 'bar', - }, - query: { - _inspect: false, - }, - }); - - await simulateRequest({ - params: { - bar: 'foo', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - - await simulateRequest({ - params: { - foo: 9, - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(2); - - await simulateRequest({ - params: { - foo: 'bar', - extra: '', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(3); - }); - - it('validates body parameters', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi( - t.type({ - body: t.string, - }) - ); - - await simulateRequest({ - body: '', - }); - - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(handlerMock).toHaveBeenCalledTimes(1); - expect(responseMock.ok).toHaveBeenCalledTimes(1); - - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - body: '', - query: { - _inspect: false, - }, - }); - - await simulateRequest({ - body: null, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - }); - - it('validates query parameters', async () => { - const { simulateRequest, handlerMock, responseMock } = initApi( - t.type({ - query: t.type({ - bar: t.string, - filterNames: jsonRt.pipe(t.array(t.string)), - }), - }) - ); - - await simulateRequest({ - query: { - bar: '', - _inspect: 'true', - filterNames: JSON.stringify(['hostName', 'agentName']), - }, - }); - - expect(responseMock.custom).not.toHaveBeenCalled(); - expect(handlerMock).toHaveBeenCalledTimes(1); - expect(responseMock.ok).toHaveBeenCalledTimes(1); - - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - query: { - bar: '', - _inspect: true, - filterNames: ['hostName', 'agentName'], - }, - }); - - await simulateRequest({ - query: { - bar: '', - foo: '', - }, - }); - - expect(responseMock.custom).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts deleted file mode 100644 index 87bc97d346984..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { merge as mergeLodash, pickBy, isEmpty, isPlainObject } from 'lodash'; -import Boom from '@hapi/boom'; -import { schema } from '@kbn/config-schema'; -import * as t from 'io-ts'; -import { PathReporter } from 'io-ts/lib/PathReporter'; -import { isLeft } from 'fp-ts/lib/Either'; -import { KibanaRequest, RouteRegistrar } from 'src/core/server'; -import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; -import agent from 'elastic-apm-node'; -import { parseMethod } from '../../../common/apm_api/parse_endpoint'; -import { merge } from '../../../common/runtime_types/merge'; -import { strictKeysRt } from '../../../common/runtime_types/strict_keys_rt'; -import { APMConfig } from '../..'; -import { InspectResponse, RouteParamsRT, ServerAPI } from '../typings'; -import { jsonRt } from '../../../common/runtime_types/json_rt'; -import type { ApmPluginRequestHandlerContext } from '../typings'; - -const inspectRt = t.exact( - t.partial({ - query: t.exact(t.partial({ _inspect: jsonRt.pipe(t.boolean) })), - }) -); - -type RouteOrRouteFactoryFn = Parameters['add']>[0]; - -const isNotEmpty = (val: any) => - val !== undefined && val !== null && !(isPlainObject(val) && isEmpty(val)); - -export const inspectableEsQueriesMap = new WeakMap< - KibanaRequest, - InspectResponse ->(); - -export function createApi() { - const routes: RouteOrRouteFactoryFn[] = []; - const api: ServerAPI<{}> = { - _S: {}, - add(route) { - routes.push((route as unknown) as RouteOrRouteFactoryFn); - return this as any; - }, - init(core, { config$, logger, plugins }) { - const router = core.http.createRouter(); - - let config = {} as APMConfig; - - config$.subscribe((val) => { - config = val; - }); - - routes.forEach((routeOrFactoryFn) => { - const route = - typeof routeOrFactoryFn === 'function' - ? routeOrFactoryFn(core) - : routeOrFactoryFn; - - const { params, endpoint, options, handler } = route; - - const [method, path] = endpoint.split(' '); - const typedRouterMethod = parseMethod(method); - - // For all runtime types with props, we create an exact - // version that will strip all keys that are unvalidated. - const anyObject = schema.object({}, { unknowns: 'allow' }); - - (router[typedRouterMethod] as RouteRegistrar< - typeof typedRouterMethod, - ApmPluginRequestHandlerContext - >)( - { - path, - options, - validate: { - // `body` can be null, but `validate` expects non-nullable types - // if any validation is defined. Not having validation currently - // means we don't get the payload. See - // https://github.com/elastic/kibana/issues/50179 - body: schema.nullable(anyObject), - params: anyObject, - query: anyObject, - }, - }, - async (context, request, response) => { - if (agent.isStarted()) { - agent.addLabels({ - plugin: 'apm', - }); - } - - // init debug queries - inspectableEsQueriesMap.set(request, []); - - try { - const validParams = validateParams(request, params); - const data = await handler({ - request, - context: { - ...context, - plugins, - params: validParams, - config, - logger, - }, - }); - - const body = { ...data }; - if (validParams.query._inspect) { - body._inspect = inspectableEsQueriesMap.get(request); - } - - // cleanup - inspectableEsQueriesMap.delete(request); - - return response.ok({ body }); - } catch (error) { - logger.error(error); - const opts = { - statusCode: 500, - body: { - message: error.message, - attributes: { - _inspect: inspectableEsQueriesMap.get(request), - }, - }, - }; - - if (Boom.isBoom(error)) { - opts.statusCode = error.output.statusCode; - } - - if (error instanceof RequestAbortedError) { - opts.statusCode = 499; - opts.body.message = 'Client closed request'; - } - - return response.custom(opts); - } - } - ); - }); - }, - }; - - return api; -} - -function validateParams( - request: KibanaRequest, - params: RouteParamsRT | undefined -) { - const paramsRt = params ? merge([params, inspectRt]) : inspectRt; - const paramMap = pickBy( - { - path: request.params, - body: request.body, - query: { - _inspect: 'false', - // @ts-ignore - ...request.query, - }, - }, - isNotEmpty - ); - - const result = strictKeysRt(paramsRt).decode(paramMap); - - if (isLeft(result)) { - throw Boom.badRequest(PathReporter.report(result)[0]); - } - - // Only return values for parameters that have runtime types, - // but always include query as _inspect is always set even if - // it's not defined in the route. - return mergeLodash( - { query: { _inspect: false } }, - pickBy(result.right, isNotEmpty) - ); -} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts deleted file mode 100644 index 5b74aa4347f14..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - staticIndexPatternRoute, - dynamicIndexPatternRoute, - apmIndexPatternTitleRoute, -} from './index_pattern'; -import { createApi } from './create_api'; -import { environmentsRoute } from './environments'; -import { - errorDistributionRoute, - errorGroupsRoute, - errorsRoute, -} from './errors'; -import { - serviceAgentNameRoute, - serviceTransactionTypesRoute, - servicesRoute, - serviceNodeMetadataRoute, - serviceAnnotationsRoute, - serviceAnnotationsCreateRoute, - serviceErrorGroupsPrimaryStatisticsRoute, - serviceErrorGroupsComparisonStatisticsRoute, - serviceThroughputRoute, - serviceDependenciesRoute, - serviceMetadataDetailsRoute, - serviceMetadataIconsRoute, - serviceInstancesPrimaryStatisticsRoute, - serviceInstancesComparisonStatisticsRoute, - serviceProfilingStatisticsRoute, - serviceProfilingTimelineRoute, -} from './services'; -import { - agentConfigurationRoute, - getSingleAgentConfigurationRoute, - agentConfigurationSearchRoute, - deleteAgentConfigurationRoute, - listAgentConfigurationEnvironmentsRoute, - listAgentConfigurationServicesRoute, - createOrUpdateAgentConfigurationRoute, - agentConfigurationAgentNameRoute, -} from './settings/agent_configuration'; -import { - apmIndexSettingsRoute, - apmIndicesRoute, - saveApmIndicesRoute, -} from './settings/apm_indices'; -import { metricsChartsRoute } from './metrics'; -import { serviceNodesRoute } from './service_nodes'; -import { - tracesRoute, - tracesByIdRoute, - rootTransactionByTraceIdRoute, -} from './traces'; -import { - correlationsLatencyDistributionRoute, - correlationsForSlowTransactionsRoute, - correlationsErrorDistributionRoute, - correlationsForFailedTransactionsRoute, -} from './correlations'; -import { - transactionChartsBreakdownRoute, - transactionChartsDistributionRoute, - transactionChartsErrorRateRoute, - transactionGroupsRoute, - transactionGroupsPrimaryStatisticsRoute, - transactionLatencyChartsRoute, - transactionThroughputChartsRoute, - transactionGroupsComparisonStatisticsRoute, -} from './transactions'; -import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map'; -import { - createCustomLinkRoute, - updateCustomLinkRoute, - deleteCustomLinkRoute, - listCustomLinksRoute, - customLinkTransactionRoute, -} from './settings/custom_link'; -import { - observabilityOverviewHasDataRoute, - observabilityOverviewRoute, -} from './observability_overview'; -import { - anomalyDetectionJobsRoute, - createAnomalyDetectionJobsRoute, - anomalyDetectionEnvironmentsRoute, -} from './settings/anomaly_detection'; -import { - rumHasDataRoute, - rumClientMetricsRoute, - rumJSErrors, - rumLongTaskMetrics, - rumOverviewLocalFiltersRoute, - rumPageLoadDistBreakdownRoute, - rumPageLoadDistributionRoute, - rumPageViewsTrendRoute, - rumServicesRoute, - rumUrlSearch, - rumVisitorsBreakdownRoute, - rumWebCoreVitals, -} from './rum_client'; -import { - transactionErrorRateChartPreview, - transactionErrorCountChartPreview, - transactionDurationChartPreview, -} from './alerts/chart_preview'; - -const createApmApi = () => { - const api = createApi() - // index pattern - .add(staticIndexPatternRoute) - .add(dynamicIndexPatternRoute) - .add(apmIndexPatternTitleRoute) - - // Environments - .add(environmentsRoute) - - // Errors - .add(errorDistributionRoute) - .add(errorGroupsRoute) - .add(errorsRoute) - - // Services - .add(serviceAgentNameRoute) - .add(serviceTransactionTypesRoute) - .add(servicesRoute) - .add(serviceNodeMetadataRoute) - .add(serviceAnnotationsRoute) - .add(serviceAnnotationsCreateRoute) - .add(serviceErrorGroupsPrimaryStatisticsRoute) - .add(serviceThroughputRoute) - .add(serviceDependenciesRoute) - .add(serviceMetadataDetailsRoute) - .add(serviceMetadataIconsRoute) - .add(serviceInstancesPrimaryStatisticsRoute) - .add(serviceInstancesComparisonStatisticsRoute) - .add(serviceErrorGroupsComparisonStatisticsRoute) - .add(serviceProfilingTimelineRoute) - .add(serviceProfilingStatisticsRoute) - - // Agent configuration - .add(getSingleAgentConfigurationRoute) - .add(agentConfigurationAgentNameRoute) - .add(agentConfigurationRoute) - .add(agentConfigurationSearchRoute) - .add(deleteAgentConfigurationRoute) - .add(listAgentConfigurationEnvironmentsRoute) - .add(listAgentConfigurationServicesRoute) - .add(createOrUpdateAgentConfigurationRoute) - - // Correlations - .add(correlationsLatencyDistributionRoute) - .add(correlationsForSlowTransactionsRoute) - .add(correlationsErrorDistributionRoute) - .add(correlationsForFailedTransactionsRoute) - - // APM indices - .add(apmIndexSettingsRoute) - .add(apmIndicesRoute) - .add(saveApmIndicesRoute) - - // Metrics - .add(metricsChartsRoute) - .add(serviceNodesRoute) - - // Traces - .add(tracesRoute) - .add(tracesByIdRoute) - .add(rootTransactionByTraceIdRoute) - - // Transactions - .add(transactionChartsBreakdownRoute) - .add(transactionChartsDistributionRoute) - .add(transactionChartsErrorRateRoute) - .add(transactionGroupsRoute) - .add(transactionGroupsPrimaryStatisticsRoute) - .add(transactionLatencyChartsRoute) - .add(transactionThroughputChartsRoute) - .add(transactionGroupsComparisonStatisticsRoute) - - // Service map - .add(serviceMapRoute) - .add(serviceMapServiceNodeRoute) - - // Custom links - .add(createCustomLinkRoute) - .add(updateCustomLinkRoute) - .add(deleteCustomLinkRoute) - .add(listCustomLinksRoute) - .add(customLinkTransactionRoute) - - // Observability dashboard - .add(observabilityOverviewHasDataRoute) - .add(observabilityOverviewRoute) - - // Anomaly detection - .add(anomalyDetectionJobsRoute) - .add(createAnomalyDetectionJobsRoute) - .add(anomalyDetectionEnvironmentsRoute) - - // User Experience app api routes - .add(rumOverviewLocalFiltersRoute) - .add(rumPageViewsTrendRoute) - .add(rumPageLoadDistributionRoute) - .add(rumPageLoadDistBreakdownRoute) - .add(rumClientMetricsRoute) - .add(rumServicesRoute) - .add(rumVisitorsBreakdownRoute) - .add(rumWebCoreVitals) - .add(rumJSErrors) - .add(rumUrlSearch) - .add(rumLongTaskMetrics) - .add(rumHasDataRoute) - - // Alerting - .add(transactionErrorCountChartPreview) - .add(transactionDurationChartPreview) - .add(transactionErrorRateChartPreview); - - return api; -}; - -export type APMAPI = ReturnType; - -export { createApmApi }; diff --git a/x-pack/plugins/apm/server/routes/create_apm_server_route.ts b/x-pack/plugins/apm/server/routes/create_apm_server_route.ts new file mode 100644 index 0000000000000..86330a87a8c55 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/create_apm_server_route.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 { createServerRouteFactory } from '@kbn/server-route-repository'; +import { APMRouteCreateOptions, APMRouteHandlerResources } from './typings'; + +export const createApmServerRoute = createServerRouteFactory< + APMRouteHandlerResources, + APMRouteCreateOptions +>(); diff --git a/x-pack/plugins/apm/server/routes/create_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/create_apm_server_route_repository.ts new file mode 100644 index 0000000000000..b7cbe890c57db --- /dev/null +++ b/x-pack/plugins/apm/server/routes/create_apm_server_route_repository.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 { createServerRouteRepository } from '@kbn/server-route-repository'; +import { APMRouteCreateOptions, APMRouteHandlerResources } from './typings'; + +export function createApmServerRouteRepository() { + return createServerRouteRepository< + APMRouteHandlerResources, + APMRouteCreateOptions + >(); +} diff --git a/x-pack/plugins/apm/server/routes/create_route.ts b/x-pack/plugins/apm/server/routes/create_route.ts deleted file mode 100644 index d74aac0992eb4..0000000000000 --- a/x-pack/plugins/apm/server/routes/create_route.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CoreSetup } from 'src/core/server'; -import { HandlerReturn, Route, RouteParamsRT } from './typings'; - -export function createRoute< - TEndpoint extends string, - TReturn extends HandlerReturn, - TRouteParamsRT extends RouteParamsRT | undefined = undefined ->( - route: Route -): Route; - -export function createRoute< - TEndpoint extends string, - TReturn extends HandlerReturn, - TRouteParamsRT extends RouteParamsRT | undefined = undefined ->( - route: (core: CoreSetup) => Route -): (core: CoreSetup) => Route; - -export function createRoute(routeOrFactoryFn: Function | object) { - return routeOrFactoryFn; -} diff --git a/x-pack/plugins/apm/server/routes/environments.ts b/x-pack/plugins/apm/server/routes/environments.ts index 4aa7d7e6d412f..e06fbdf7fb6d4 100644 --- a/x-pack/plugins/apm/server/routes/environments.ts +++ b/x-pack/plugins/apm/server/routes/environments.ts @@ -9,10 +9,11 @@ import * as t from 'io-ts'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; import { getEnvironments } from '../lib/environments/get_environments'; -import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const environmentsRoute = createRoute({ +const environmentsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/environments', params: t.type({ query: t.intersection([ @@ -23,9 +24,10 @@ export const environmentsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -39,3 +41,7 @@ export const environmentsRoute = createRoute({ return { environments }; }, }); + +export const environmentsRouteRepository = createApmServerRouteRepository().add( + environmentsRoute +); diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index f69d3fc9631d1..d6bb1d4bcbaae 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -6,14 +6,15 @@ */ import * as t from 'io-ts'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; import { getErrorDistribution } from '../lib/errors/distribution/get_distribution'; import { getErrorGroupSample } from '../lib/errors/get_error_group_sample'; import { getErrorGroups } from '../lib/errors/get_error_groups'; import { setupRequest } from '../lib/helpers/setup_request'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const errorsRoute = createRoute({ +const errorsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/errors', params: t.type({ path: t.type({ @@ -30,9 +31,9 @@ export const errorsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); const { serviceName } = params.path; const { environment, kuery, sortField, sortDirection } = params.query; @@ -49,7 +50,7 @@ export const errorsRoute = createRoute({ }, }); -export const errorGroupsRoute = createRoute({ +const errorGroupsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/errors/{groupId}', params: t.type({ path: t.type({ @@ -59,10 +60,11 @@ export const errorGroupsRoute = createRoute({ query: t.intersection([environmentRt, kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName, groupId } = context.params.path; - const { environment, kuery } = context.params.query; + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); + const { serviceName, groupId } = params.path; + const { environment, kuery } = params.query; return getErrorGroupSample({ environment, @@ -74,7 +76,7 @@ export const errorGroupsRoute = createRoute({ }, }); -export const errorDistributionRoute = createRoute({ +const errorDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/errors/distribution', params: t.type({ path: t.type({ @@ -90,9 +92,9 @@ export const errorDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { serviceName } = params.path; const { environment, kuery, groupId } = params.query; return getErrorDistribution({ @@ -104,3 +106,8 @@ export const errorDistributionRoute = createRoute({ }); }, }); + +export const errorsRouteRepository = createApmServerRouteRepository() + .add(errorsRoute) + .add(errorGroupsRoute) + .add(errorDistributionRoute); diff --git a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts new file mode 100644 index 0000000000000..c151752b4b6e0 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { + ServerRouteRepository, + ReturnOf, + EndpointOf, +} from '@kbn/server-route-repository'; +import { PickByValue } from 'utility-types'; +import { alertsChartPreviewRouteRepository } from './alerts/chart_preview'; +import { correlationsRouteRepository } from './correlations'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { environmentsRouteRepository } from './environments'; +import { errorsRouteRepository } from './errors'; +import { indexPatternRouteRepository } from './index_pattern'; +import { metricsRouteRepository } from './metrics'; +import { observabilityOverviewRouteRepository } from './observability_overview'; +import { rumRouteRepository } from './rum_client'; +import { serviceRouteRepository } from './services'; +import { serviceMapRouteRepository } from './service_map'; +import { serviceNodeRouteRepository } from './service_nodes'; +import { agentConfigurationRouteRepository } from './settings/agent_configuration'; +import { anomalyDetectionRouteRepository } from './settings/anomaly_detection'; +import { apmIndicesRouteRepository } from './settings/apm_indices'; +import { customLinkRouteRepository } from './settings/custom_link'; +import { traceRouteRepository } from './traces'; +import { transactionRouteRepository } from './transactions'; +import { APMRouteHandlerResources } from './typings'; + +const getTypedGlobalApmServerRouteRepository = () => { + const repository = createApmServerRouteRepository() + .merge(indexPatternRouteRepository) + .merge(environmentsRouteRepository) + .merge(errorsRouteRepository) + .merge(metricsRouteRepository) + .merge(observabilityOverviewRouteRepository) + .merge(rumRouteRepository) + .merge(serviceMapRouteRepository) + .merge(serviceNodeRouteRepository) + .merge(serviceRouteRepository) + .merge(traceRouteRepository) + .merge(transactionRouteRepository) + .merge(alertsChartPreviewRouteRepository) + .merge(correlationsRouteRepository) + .merge(agentConfigurationRouteRepository) + .merge(anomalyDetectionRouteRepository) + .merge(apmIndicesRouteRepository) + .merge(customLinkRouteRepository); + + return repository; +}; + +const getGlobalApmServerRouteRepository = () => { + return getTypedGlobalApmServerRouteRepository() as ServerRouteRepository; +}; + +export type APMServerRouteRepository = ReturnType< + typeof getTypedGlobalApmServerRouteRepository +>; + +// Ensure no APIs return arrays (or, by proxy, the any type), +// to guarantee compatibility with _inspect. + +type CompositeEndpoint = EndpointOf; + +type EndpointReturnTypes = { + [Endpoint in CompositeEndpoint]: ReturnOf; +}; + +type ArrayLikeReturnTypes = PickByValue; + +type ViolatingEndpoints = keyof ArrayLikeReturnTypes; + +function assertType() {} + +// if any endpoint has an array-like return type, the assertion below will fail +assertType(); + +export { getGlobalApmServerRouteRepository }; diff --git a/x-pack/plugins/apm/server/routes/index_pattern.ts b/x-pack/plugins/apm/server/routes/index_pattern.ts index 3b800c23135ce..aa70cde4f96ae 100644 --- a/x-pack/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/plugins/apm/server/routes/index_pattern.ts @@ -6,49 +6,67 @@ */ import { createStaticIndexPattern } from '../lib/index_pattern/create_static_index_pattern'; -import { createRoute } from './create_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; 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 { createApmServerRoute } from './create_apm_server_route'; -export const staticIndexPatternRoute = createRoute((core) => ({ +const staticIndexPatternRoute = createApmServerRoute({ endpoint: 'POST /api/apm/index_pattern/static', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { + request, + core, + plugins: { spaces }, + config, + } = resources; + const [setup, savedObjectsClient] = await Promise.all([ - setupRequest(context, request), - getInternalSavedObjectsClient(core), + setupRequest(resources), + core + .start() + .then((coreStart) => coreStart.savedObjects.createInternalRepository()), ]); - const spaceId = context.plugins.spaces?.spacesService.getSpaceId(request); + const spaceId = spaces?.setup.spacesService.getSpaceId(request); const didCreateIndexPattern = await createStaticIndexPattern( setup, - context, + config, savedObjectsClient, spaceId ); return { created: didCreateIndexPattern }; }, -})); +}); -export const dynamicIndexPatternRoute = createRoute({ +const dynamicIndexPatternRoute = createApmServerRoute({ endpoint: 'GET /api/apm/index_pattern/dynamic', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { - const dynamicIndexPattern = await getDynamicIndexPattern({ context }); + handler: async ({ context, config, logger }) => { + const dynamicIndexPattern = await getDynamicIndexPattern({ + context, + config, + logger, + }); return { dynamicIndexPattern }; }, }); -export const apmIndexPatternTitleRoute = createRoute({ +const indexPatternTitleRoute = createApmServerRoute({ endpoint: 'GET /api/apm/index_pattern/title', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { + handler: async ({ config }) => { return { - indexPatternTitle: getApmIndexPatternTitle(context), + indexPatternTitle: getApmIndexPatternTitle(config), }; }, }); + +export const indexPatternRouteRepository = createApmServerRouteRepository() + .add(staticIndexPatternRoute) + .add(dynamicIndexPatternRoute) + .add(indexPatternTitleRoute); diff --git a/x-pack/plugins/apm/server/routes/metrics.ts b/x-pack/plugins/apm/server/routes/metrics.ts index c7e82e13d07b8..9fa2346eb72fb 100644 --- a/x-pack/plugins/apm/server/routes/metrics.ts +++ b/x-pack/plugins/apm/server/routes/metrics.ts @@ -8,10 +8,11 @@ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getMetricsChartDataByAgent } from '../lib/metrics/get_metrics_chart_data_by_agent'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; -export const metricsChartsRoute = createRoute({ +const metricsChartsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/metrics/charts', params: t.type({ path: t.type({ @@ -30,9 +31,9 @@ export const metricsChartsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); const { serviceName } = params.path; const { agentName, environment, kuery, serviceNodeName } = params.query; return await getMetricsChartDataByAgent({ @@ -45,3 +46,7 @@ export const metricsChartsRoute = createRoute({ }); }, }); + +export const metricsRouteRepository = createApmServerRouteRepository().add( + metricsChartsRoute +); diff --git a/x-pack/plugins/apm/server/routes/observability_overview.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts index 1aac2c09d01c5..d459570cf7337 100644 --- a/x-pack/plugins/apm/server/routes/observability_overview.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -10,30 +10,32 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceCount } from '../lib/observability_overview/get_service_count'; import { getTransactionsPerMinute } from '../lib/observability_overview/get_transactions_per_minute'; import { getHasData } from '../lib/observability_overview/has_data'; -import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { withApmSpan } from '../utils/with_apm_span'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { createApmServerRoute } from './create_apm_server_route'; -export const observabilityOverviewHasDataRoute = createRoute({ +const observabilityOverviewHasDataRoute = createApmServerRoute({ endpoint: 'GET /api/apm/observability_overview/has_data', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const res = await getHasData({ setup }); return { hasData: res }; }, }); -export const observabilityOverviewRoute = createRoute({ +const observabilityOverviewRoute = createApmServerRoute({ endpoint: 'GET /api/apm/observability_overview', params: t.type({ query: t.intersection([rangeRt, t.type({ bucketSize: t.string })]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { bucketSize } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { bucketSize } = resources.params.query; + const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -54,3 +56,7 @@ export const observabilityOverviewRoute = createRoute({ }); }, }); + +export const observabilityOverviewRouteRepository = createApmServerRouteRepository() + .add(observabilityOverviewRoute) + .add(observabilityOverviewHasDataRoute); diff --git a/x-pack/plugins/apm/server/routes/register_routes/index.test.ts b/x-pack/plugins/apm/server/routes/register_routes/index.test.ts new file mode 100644 index 0000000000000..82b73d46da5c1 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/register_routes/index.test.ts @@ -0,0 +1,507 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { jsonRt } from '@kbn/io-ts-utils'; +import { createServerRouteRepository } from '@kbn/server-route-repository'; +import { ServerRoute } from '@kbn/server-route-repository/target/typings'; +import * as t from 'io-ts'; +import { CoreSetup, Logger } from 'src/core/server'; +import { APMConfig } from '../..'; +import { APMRouteCreateOptions, APMRouteHandlerResources } from '../typings'; +import { registerRoutes } from './index'; + +type RegisterRouteDependencies = Parameters[0]; + +const getRegisterRouteDependencies = () => { + const get = jest.fn(); + const post = jest.fn(); + const put = jest.fn(); + const createRouter = jest.fn().mockReturnValue({ + get, + post, + put, + }); + + const coreSetup = ({ + http: { + createRouter, + }, + } as unknown) as CoreSetup; + + const logger = ({ + error: jest.fn(), + } as unknown) as Logger; + + return { + mocks: { + get, + post, + put, + createRouter, + coreSetup, + logger, + }, + dependencies: ({ + core: { + setup: coreSetup, + }, + logger, + config: {} as APMConfig, + plugins: {}, + } as unknown) as RegisterRouteDependencies, + }; +}; + +const getRepository = () => + createServerRouteRepository< + APMRouteHandlerResources, + APMRouteCreateOptions + >(); + +const initApi = ( + routes: Array< + ServerRoute< + any, + t.Any, + APMRouteHandlerResources, + any, + APMRouteCreateOptions + > + > +) => { + const { mocks, dependencies } = getRegisterRouteDependencies(); + + let repository = getRepository(); + + routes.forEach((route) => { + repository = repository.add(route); + }); + + registerRoutes({ + ...dependencies, + repository, + }); + + const responseMock = { + ok: jest.fn(), + custom: jest.fn(), + }; + + const simulateRequest = (request: { + method: 'get' | 'post' | 'put'; + pathname: string; + params?: Record; + body?: unknown; + query?: Record; + }) => { + const [, registeredRouteHandler] = + mocks[request.method].mock.calls.find((call) => { + return call[0].path === request.pathname; + }) ?? []; + + const result = registeredRouteHandler( + {}, + { + params: {}, + query: {}, + body: null, + ...request, + }, + responseMock + ); + + return result; + }; + + return { + simulateRequest, + mocks: { + ...mocks, + response: responseMock, + }, + }; +}; + +describe('createApi', () => { + it('registers a route with the server', () => { + const { + mocks: { createRouter, get, post, put }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { tags: ['access:apm'] }, + handler: async () => ({}), + }, + { + endpoint: 'POST /bar', + params: t.type({ + body: t.string, + }), + options: { tags: ['access:apm'] }, + handler: async () => ({}), + }, + { + endpoint: 'PUT /baz', + options: { + tags: ['access:apm', 'access:apm_write'], + }, + handler: async () => ({}), + }, + { + endpoint: 'GET /qux', + options: { + tags: ['access:apm', 'access:apm_write'], + }, + handler: async () => ({}), + }, + ]); + + expect(createRouter).toHaveBeenCalledTimes(1); + + expect(get).toHaveBeenCalledTimes(2); + expect(post).toHaveBeenCalledTimes(1); + expect(put).toHaveBeenCalledTimes(1); + + expect(get.mock.calls[0][0]).toEqual({ + options: { + tags: ['access:apm'], + }, + path: '/foo', + validate: expect.anything(), + }); + + expect(get.mock.calls[1][0]).toEqual({ + options: { + tags: ['access:apm', 'access:apm_write'], + }, + path: '/qux', + validate: expect.anything(), + }); + + expect(post.mock.calls[0][0]).toEqual({ + options: { + tags: ['access:apm'], + }, + path: '/bar', + validate: expect.anything(), + }); + + expect(put.mock.calls[0][0]).toEqual({ + options: { + tags: ['access:apm', 'access:apm_write'], + }, + path: '/baz', + validate: expect.anything(), + }); + }); + + describe('when validating', () => { + describe('_inspect', () => { + it('allows _inspect=true', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { _inspect: 'true' }, + }); + + // responds with ok + expect(response.custom).not.toHaveBeenCalled(); + + const params = handlerMock.mock.calls[0][0].params; + expect(params).toEqual({ query: { _inspect: true } }); + expect(handlerMock).toHaveBeenCalledTimes(1); + expect(response.ok).toHaveBeenCalledWith({ + body: { _inspect: [] }, + }); + }); + + it('rejects _inspect=1', async () => { + const handlerMock = jest.fn(); + + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + handler: handlerMock, + }, + ]); + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { _inspect: 1 }, + }); + + // responds with error handler + expect(response.ok).not.toHaveBeenCalled(); + expect(response.custom).toHaveBeenCalledWith({ + body: { + attributes: { _inspect: [] }, + message: + 'Invalid value 1 supplied to : strict_keys/query: Partial<{| _inspect: pipe(JSON, boolean) |}>/_inspect: pipe(JSON, boolean)', + }, + statusCode: 400, + }); + }); + + it('allows omitting _inspect', async () => { + const handlerMock = jest.fn(); + + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { endpoint: 'GET /foo', options: { tags: [] }, handler: handlerMock }, + ]); + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: {}, + }); + + // responds with ok + expect(response.custom).not.toHaveBeenCalled(); + + const params = handlerMock.mock.calls[0][0].params; + expect(params).toEqual({ query: { _inspect: false } }); + expect(handlerMock).toHaveBeenCalledTimes(1); + + expect(response.ok).toHaveBeenCalledWith({ body: {} }); + }); + }); + + it('throws if unknown parameters are provided', async () => { + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { endpoint: 'GET /foo', options: { tags: [] }, handler: jest.fn() }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { _inspect: 'true', extra: '' }, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + body: { foo: 'bar' }, + }); + + expect(response.custom).toHaveBeenCalledTimes(2); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 'bar', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(3); + }); + + it('validates path parameters', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { tags: [] }, + params: t.type({ + path: t.type({ + foo: t.string, + }), + }), + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 'bar', + }, + }); + + expect(handlerMock).toHaveBeenCalledTimes(1); + + expect(response.ok).toHaveBeenCalledTimes(1); + expect(response.custom).not.toHaveBeenCalled(); + + const params = handlerMock.mock.calls[0][0].params; + + expect(params).toEqual({ + path: { + foo: 'bar', + }, + query: { + _inspect: false, + }, + }); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + bar: 'foo', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 9, + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(2); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + params: { + foo: 'bar', + extra: '', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(3); + }); + + it('validates body parameters', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + params: t.type({ + body: t.string, + }), + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + body: '', + }); + + expect(response.custom).not.toHaveBeenCalled(); + expect(handlerMock).toHaveBeenCalledTimes(1); + expect(response.ok).toHaveBeenCalledTimes(1); + + const params = handlerMock.mock.calls[0][0].params; + + expect(params).toEqual({ + body: '', + query: { + _inspect: false, + }, + }); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + body: null, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + }); + + it('validates query parameters', async () => { + const handlerMock = jest.fn(); + const { + simulateRequest, + mocks: { response }, + } = initApi([ + { + endpoint: 'GET /foo', + options: { + tags: [], + }, + params: t.type({ + query: t.type({ + bar: t.string, + filterNames: jsonRt.pipe(t.array(t.string)), + }), + }), + handler: handlerMock, + }, + ]); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { + bar: '', + _inspect: 'true', + filterNames: JSON.stringify(['hostName', 'agentName']), + }, + }); + + expect(response.custom).not.toHaveBeenCalled(); + expect(handlerMock).toHaveBeenCalledTimes(1); + expect(response.ok).toHaveBeenCalledTimes(1); + + const params = handlerMock.mock.calls[0][0].params; + + expect(params).toEqual({ + query: { + bar: '', + _inspect: true, + filterNames: ['hostName', 'agentName'], + }, + }); + + await simulateRequest({ + method: 'get', + pathname: '/foo', + query: { + bar: '', + foo: '', + }, + }); + + expect(response.custom).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/routes/register_routes/index.ts b/x-pack/plugins/apm/server/routes/register_routes/index.ts new file mode 100644 index 0000000000000..3a88a496b923f --- /dev/null +++ b/x-pack/plugins/apm/server/routes/register_routes/index.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import * as t from 'io-ts'; +import { KibanaRequest, RouteRegistrar } from 'src/core/server'; +import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; +import agent from 'elastic-apm-node'; +import { ServerRouteRepository } from '@kbn/server-route-repository'; +import { merge } from 'lodash'; +import { + decodeRequestParams, + parseEndpoint, + routeValidationObject, +} from '@kbn/server-route-repository'; +import { mergeRt, jsonRt } from '@kbn/io-ts-utils'; +import { pickKeys } from '../../../common/utils/pick_keys'; +import { APMRouteHandlerResources, InspectResponse } from '../typings'; +import type { ApmPluginRequestHandlerContext } from '../typings'; + +const inspectRt = t.exact( + t.partial({ + query: t.exact(t.partial({ _inspect: jsonRt.pipe(t.boolean) })), + }) +); + +export const inspectableEsQueriesMap = new WeakMap< + KibanaRequest, + InspectResponse +>(); + +export function registerRoutes({ + core, + repository, + plugins, + logger, + config, +}: { + core: APMRouteHandlerResources['core']; + plugins: APMRouteHandlerResources['plugins']; + logger: APMRouteHandlerResources['logger']; + repository: ServerRouteRepository; + config: APMRouteHandlerResources['config']; +}) { + const routes = repository.getRoutes(); + + const router = core.setup.http.createRouter(); + + routes.forEach((route) => { + const { params, endpoint, options, handler } = route; + + const { method, pathname } = parseEndpoint(endpoint); + + (router[method] as RouteRegistrar< + typeof method, + ApmPluginRequestHandlerContext + >)( + { + path: pathname, + options, + validate: routeValidationObject, + }, + async (context, request, response) => { + if (agent.isStarted()) { + agent.addLabels({ + plugin: 'apm', + }); + } + + // init debug queries + inspectableEsQueriesMap.set(request, []); + + try { + const runtimeType = params ? mergeRt(params, inspectRt) : inspectRt; + + const validatedParams = decodeRequestParams( + pickKeys(request, 'params', 'body', 'query'), + runtimeType + ); + + const data: Record | undefined | null = (await handler({ + request, + context, + config, + logger, + core, + plugins, + params: merge( + { + query: { + _inspect: false, + }, + }, + validatedParams + ), + })) as any; + + if (Array.isArray(data)) { + throw new Error('Return type cannot be an array'); + } + + const body = validatedParams.query?._inspect + ? { + ...data, + _inspect: inspectableEsQueriesMap.get(request), + } + : { ...data }; + + // cleanup + inspectableEsQueriesMap.delete(request); + + return response.ok({ body }); + } catch (error) { + logger.error(error); + const opts = { + statusCode: 500, + body: { + message: error.message, + attributes: { + _inspect: inspectableEsQueriesMap.get(request), + }, + }, + }; + + if (Boom.isBoom(error)) { + opts.statusCode = error.output.statusCode; + } + + if (error instanceof RequestAbortedError) { + opts.statusCode = 499; + opts.body.message = 'Client closed request'; + } + + return response.custom(opts); + } + } + ); + }); +} diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index 3156acb469a72..d7f91adc0d683 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { jsonRt } from '../../common/runtime_types/json_rt'; +import { jsonRt } from '@kbn/io-ts-utils'; import { LocalUIFilterName } from '../../common/ui_filter'; import { Setup, @@ -28,9 +28,10 @@ import { getLocalUIFilters } from '../lib/rum_client/ui_filters/local_ui_filters import { localUIFilterNames } from '../lib/rum_client/ui_filters/local_ui_filters/config'; import { getRumPageLoadTransactionsProjection } from '../projections/rum_page_load_transactions'; import { Projection } from '../projections/typings'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { rangeRt } from './default_api_types'; -import { APMRequestHandlerContext } from './typings'; +import { APMRouteHandlerResources } from './typings'; export const percentileRangeRt = t.partial({ minPercentile: t.string, @@ -45,18 +46,18 @@ const uxQueryRt = t.intersection([ t.partial({ urlQuery: t.string, percentile: t.string }), ]); -export const rumClientMetricsRoute = createRoute({ +const rumClientMetricsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum/client-metrics', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getClientMetrics({ setup, @@ -66,18 +67,18 @@ export const rumClientMetricsRoute = createRoute({ }, }); -export const rumPageLoadDistributionRoute = createRoute({ +const rumPageLoadDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/page-load-distribution', params: t.type({ query: t.intersection([uxQueryRt, percentileRangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { minPercentile, maxPercentile, urlQuery }, - } = context.params; + } = resources.params; const pageLoadDistribution = await getPageLoadDistribution({ setup, @@ -90,7 +91,7 @@ export const rumPageLoadDistributionRoute = createRoute({ }, }); -export const rumPageLoadDistBreakdownRoute = createRoute({ +const rumPageLoadDistBreakdownRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/page-load-distribution/breakdown', params: t.type({ query: t.intersection([ @@ -100,12 +101,12 @@ export const rumPageLoadDistBreakdownRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { minPercentile, maxPercentile, breakdown, urlQuery }, - } = context.params; + } = resources.params; const pageLoadDistBreakdown = await getPageLoadDistBreakdown({ setup, @@ -119,18 +120,18 @@ export const rumPageLoadDistBreakdownRoute = createRoute({ }, }); -export const rumPageViewsTrendRoute = createRoute({ +const rumPageViewsTrendRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/page-view-trends', params: t.type({ query: t.intersection([uxQueryRt, t.partial({ breakdowns: t.string })]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { breakdowns, urlQuery }, - } = context.params; + } = resources.params; return getPageViewTrends({ setup, @@ -140,32 +141,32 @@ export const rumPageViewsTrendRoute = createRoute({ }, }); -export const rumServicesRoute = createRoute({ +const rumServicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/services', params: t.type({ query: t.intersection([uiFiltersRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const rumServices = await getRumServices({ setup }); return { rumServices }; }, }); -export const rumVisitorsBreakdownRoute = createRoute({ +const rumVisitorsBreakdownRoute = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/visitor-breakdown', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery }, - } = context.params; + } = resources.params; return getVisitorBreakdown({ setup, @@ -174,18 +175,18 @@ export const rumVisitorsBreakdownRoute = createRoute({ }, }); -export const rumWebCoreVitals = createRoute({ +const rumWebCoreVitals = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/web-core-vitals', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getWebCoreVitals({ setup, @@ -195,18 +196,18 @@ export const rumWebCoreVitals = createRoute({ }, }); -export const rumLongTaskMetrics = createRoute({ +const rumLongTaskMetrics = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/long-task-metrics', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getLongTaskMetrics({ setup, @@ -216,24 +217,24 @@ export const rumLongTaskMetrics = createRoute({ }, }); -export const rumUrlSearch = createRoute({ +const rumUrlSearch = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/url-search', params: t.type({ query: uxQueryRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { urlQuery, percentile }, - } = context.params; + } = resources.params; return getUrlSearch({ setup, urlQuery, percentile: Number(percentile) }); }, }); -export const rumJSErrors = createRoute({ +const rumJSErrors = createApmServerRoute({ endpoint: 'GET /api/apm/rum-client/js-errors', params: t.type({ query: t.intersection([ @@ -244,12 +245,12 @@ export const rumJSErrors = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { query: { pageSize, pageIndex, urlQuery }, - } = context.params; + } = resources.params; return getJSErrors({ setup, @@ -260,14 +261,14 @@ export const rumJSErrors = createRoute({ }, }); -export const rumHasDataRoute = createRoute({ +const rumHasDataRoute = createApmServerRoute({ endpoint: 'GET /api/apm/observability_overview/has_rum_data', params: t.type({ query: t.intersection([uiFiltersRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); return await hasRumData({ setup }); }, }); @@ -309,21 +310,22 @@ function createLocalFiltersRoute< >; queryRt: TQueryRT; }) { - return createRoute({ + return createApmServerRoute({ endpoint, params: t.type({ query: t.intersection([localUiBaseQueryRt, queryRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const { uiFilters } = setup; - const { query } = context.params; + + const { query } = resources.params; const { filterNames } = query; const projection = await getProjection({ query, - context, + resources, setup, }); @@ -339,7 +341,7 @@ function createLocalFiltersRoute< }); } -export const rumOverviewLocalFiltersRoute = createLocalFiltersRoute({ +const rumOverviewLocalFiltersRoute = createLocalFiltersRoute({ endpoint: 'GET /api/apm/rum/local_filters', getProjection: async ({ setup }) => { return getRumPageLoadTransactionsProjection({ @@ -357,9 +359,23 @@ type GetProjection< > = ({ query, setup, - context, + resources, }: { query: t.TypeOf; setup: Setup & SetupTimeRange; - context: APMRequestHandlerContext; + resources: APMRouteHandlerResources; }) => Promise | TProjection; + +export const rumRouteRepository = createApmServerRouteRepository() + .add(rumClientMetricsRoute) + .add(rumPageLoadDistributionRoute) + .add(rumPageLoadDistBreakdownRoute) + .add(rumPageViewsTrendRoute) + .add(rumServicesRoute) + .add(rumVisitorsBreakdownRoute) + .add(rumWebCoreVitals) + .add(rumLongTaskMetrics) + .add(rumUrlSearch) + .add(rumJSErrors) + .add(rumHasDataRoute) + .add(rumOverviewLocalFiltersRoute); diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 33943d6e05d01..267479de4c102 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -11,13 +11,14 @@ import { invalidLicenseMessage } from '../../common/service_map'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; import { environmentRt, rangeRt } from './default_api_types'; import { notifyFeatureUsage } from '../feature'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { isActivePlatinumLicense } from '../../common/license_check'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const serviceMapRoute = createRoute({ +const serviceMapRoute = createApmServerRoute({ endpoint: 'GET /api/apm/service-map', params: t.type({ query: t.intersection([ @@ -29,8 +30,9 @@ export const serviceMapRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - if (!context.config['xpack.apm.serviceMapEnabled']) { + handler: async (resources) => { + const { config, context, params, logger } = resources; + if (!config['xpack.apm.serviceMapEnabled']) { throw Boom.notFound(); } if (!isActivePlatinumLicense(context.licensing.license)) { @@ -42,11 +44,10 @@ export const serviceMapRoute = createRoute({ featureName: 'serviceMaps', }); - const logger = context.logger; - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { query: { serviceName, environment }, - } = context.params; + } = params; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -61,7 +62,7 @@ export const serviceMapRoute = createRoute({ }, }); -export const serviceMapServiceNodeRoute = createRoute({ +const serviceMapServiceNodeRoute = createApmServerRoute({ endpoint: 'GET /api/apm/service-map/service/{serviceName}', params: t.type({ path: t.type({ @@ -70,19 +71,21 @@ export const serviceMapServiceNodeRoute = createRoute({ query: t.intersection([environmentRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - if (!context.config['xpack.apm.serviceMapEnabled']) { + handler: async (resources) => { + const { config, context, params } = resources; + + if (!config['xpack.apm.serviceMapEnabled']) { throw Boom.notFound(); } if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(invalidLicenseMessage); } - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const { path: { serviceName }, query: { environment }, - } = context.params; + } = params; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -96,3 +99,7 @@ export const serviceMapServiceNodeRoute = createRoute({ }); }, }); + +export const serviceMapRouteRepository = createApmServerRouteRepository() + .add(serviceMapRoute) + .add(serviceMapServiceNodeRoute); diff --git a/x-pack/plugins/apm/server/routes/service_nodes.ts b/x-pack/plugins/apm/server/routes/service_nodes.ts index e9060688c63a6..a2eb12662cbca 100644 --- a/x-pack/plugins/apm/server/routes/service_nodes.ts +++ b/x-pack/plugins/apm/server/routes/service_nodes.ts @@ -6,12 +6,13 @@ */ import * as t from 'io-ts'; -import { createRoute } from './create_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { createApmServerRoute } from './create_apm_server_route'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceNodes } from '../lib/service_nodes'; import { rangeRt, kueryRt } from './default_api_types'; -export const serviceNodesRoute = createRoute({ +const serviceNodesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/serviceNodes', params: t.type({ path: t.type({ @@ -20,9 +21,9 @@ export const serviceNodesRoute = createRoute({ query: t.intersection([kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { serviceName } = params.path; const { kuery } = params.query; @@ -30,3 +31,7 @@ export const serviceNodesRoute = createRoute({ return { serviceNodes }; }, }); + +export const serviceNodeRouteRepository = createApmServerRouteRepository().add( + serviceNodesRoute +); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index b4d25ca8b2a06..800a5bdcc5d5f 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -6,15 +6,12 @@ */ import Boom from '@hapi/boom'; +import { jsonRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; import { uniq } from 'lodash'; -import { - LatencyAggregationType, - latencyAggregationTypeRt, -} from '../../common/latency_aggregation_types'; +import { latencyAggregationTypeRt } from '../../common/latency_aggregation_types'; import { ProfilingValueType } from '../../common/profiling'; import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; -import { jsonRt } from '../../common/runtime_types/json_rt'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; @@ -35,7 +32,8 @@ import { getServiceProfilingStatistics } from '../lib/services/profiling/get_ser import { getServiceProfilingTimeline } from '../lib/services/profiling/get_service_profiling_timeline'; import { offsetPreviousPeriodCoordinates } from '../utils/offset_previous_period_coordinate'; import { withApmSpan } from '../utils/with_apm_span'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { comparisonRangeRt, environmentRt, @@ -43,15 +41,16 @@ import { rangeRt, } from './default_api_types'; -export const servicesRoute = createRoute({ +const servicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services', params: t.type({ query: t.intersection([environmentRt, kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { environment, kuery } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + const { environment, kuery } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -61,21 +60,22 @@ export const servicesRoute = createRoute({ kuery, setup, searchAggregatedTransactions, - logger: context.logger, + logger, }); }, }); -export const serviceMetadataDetailsRoute = createRoute({ +const serviceMetadataDetailsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/metadata/details', params: t.type({ path: t.type({ serviceName: t.string }), query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -89,16 +89,17 @@ export const serviceMetadataDetailsRoute = createRoute({ }, }); -export const serviceMetadataIconsRoute = createRoute({ +const serviceMetadataIconsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/metadata/icons', params: t.type({ path: t.type({ serviceName: t.string }), query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -112,7 +113,7 @@ export const serviceMetadataIconsRoute = createRoute({ }, }); -export const serviceAgentNameRoute = createRoute({ +const serviceAgentNameRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/agent_name', params: t.type({ path: t.type({ @@ -121,9 +122,10 @@ export const serviceAgentNameRoute = createRoute({ query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -136,7 +138,7 @@ export const serviceAgentNameRoute = createRoute({ }, }); -export const serviceTransactionTypesRoute = createRoute({ +const serviceTransactionTypesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transaction_types', params: t.type({ path: t.type({ @@ -145,9 +147,11 @@ export const serviceTransactionTypesRoute = createRoute({ query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + return getServiceTransactionTypes({ serviceName, setup, @@ -158,7 +162,7 @@ export const serviceTransactionTypesRoute = createRoute({ }, }); -export const serviceNodeMetadataRoute = createRoute({ +const serviceNodeMetadataRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/node/{serviceNodeName}/metadata', params: t.type({ @@ -169,10 +173,11 @@ export const serviceNodeMetadataRoute = createRoute({ query: t.intersection([kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName, serviceNodeName } = context.params.path; - const { kuery } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName, serviceNodeName } = params.path; + const { kuery } = params.query; return getServiceNodeMetadata({ kuery, @@ -183,7 +188,7 @@ export const serviceNodeMetadataRoute = createRoute({ }, }); -export const serviceAnnotationsRoute = createRoute({ +const serviceAnnotationsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/annotation/search', params: t.type({ path: t.type({ @@ -192,12 +197,13 @@ export const serviceAnnotationsRoute = createRoute({ query: t.intersection([environmentRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { environment } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, plugins, context, request, logger } = resources; + const { serviceName } = params.path; + const { environment } = params.query; - const { observability } = context.plugins; + const { observability } = plugins; const [ annotationsClient, @@ -205,7 +211,7 @@ export const serviceAnnotationsRoute = createRoute({ ] = await Promise.all([ observability ? withApmSpan('get_scoped_annotations_client', () => - observability.getScopedAnnotationsClient(context, request) + observability.setup.getScopedAnnotationsClient(context, request) ) : undefined, getSearchAggregatedTransactions(setup), @@ -218,12 +224,12 @@ export const serviceAnnotationsRoute = createRoute({ serviceName, annotationsClient, client: context.core.elasticsearch.client.asCurrentUser, - logger: context.logger, + logger, }); }, }); -export const serviceAnnotationsCreateRoute = createRoute({ +const serviceAnnotationsCreateRoute = createApmServerRoute({ endpoint: 'POST /api/apm/services/{serviceName}/annotation', options: { tags: ['access:apm', 'access:apm_write'], @@ -250,12 +256,17 @@ export const serviceAnnotationsCreateRoute = createRoute({ }), ]), }), - handler: async ({ request, context }) => { - const { observability } = context.plugins; + handler: async (resources) => { + const { + request, + context, + plugins: { observability }, + params, + } = resources; const annotationsClient = observability ? await withApmSpan('get_scoped_annotations_client', () => - observability.getScopedAnnotationsClient(context, request) + observability.setup.getScopedAnnotationsClient(context, request) ) : undefined; @@ -263,7 +274,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ throw Boom.notFound(); } - const { body, path } = context.params; + const { body, path } = params; return withApmSpan('create_annotation', () => annotationsClient.create({ @@ -283,7 +294,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ }, }); -export const serviceErrorGroupsPrimaryStatisticsRoute = createRoute({ +const serviceErrorGroupsPrimaryStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/error_groups/primary_statistics', params: t.type({ @@ -300,13 +311,14 @@ export const serviceErrorGroupsPrimaryStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { path: { serviceName }, query: { kuery, transactionType, environment }, - } = context.params; + } = params; return getServiceErrorGroupPrimaryStatistics({ kuery, serviceName, @@ -317,7 +329,7 @@ export const serviceErrorGroupsPrimaryStatisticsRoute = createRoute({ }, }); -export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ +const serviceErrorGroupsComparisonStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/error_groups/comparison_statistics', params: t.type({ @@ -337,8 +349,9 @@ export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { path: { serviceName }, @@ -351,7 +364,7 @@ export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ comparisonStart, comparisonEnd, }, - } = context.params; + } = params; return getServiceErrorGroupPeriods({ environment, @@ -367,7 +380,7 @@ export const serviceErrorGroupsComparisonStatisticsRoute = createRoute({ }, }); -export const serviceThroughputRoute = createRoute({ +const serviceThroughputRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/throughput', params: t.type({ path: t.type({ @@ -382,16 +395,17 @@ export const serviceThroughputRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const { environment, kuery, transactionType, comparisonStart, comparisonEnd, - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -432,7 +446,7 @@ export const serviceThroughputRoute = createRoute({ }, }); -export const serviceInstancesPrimaryStatisticsRoute = createRoute({ +const serviceInstancesPrimaryStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics', params: t.type({ @@ -450,12 +464,16 @@ export const serviceInstancesPrimaryStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { environment, kuery, transactionType } = context.params.query; - const latencyAggregationType = (context.params.query - .latencyAggregationType as unknown) as LatencyAggregationType; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + const { + environment, + kuery, + transactionType, + latencyAggregationType, + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -479,7 +497,7 @@ export const serviceInstancesPrimaryStatisticsRoute = createRoute({ }, }); -export const serviceInstancesComparisonStatisticsRoute = createRoute({ +const serviceInstancesComparisonStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics', params: t.type({ @@ -500,9 +518,10 @@ export const serviceInstancesComparisonStatisticsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const { environment, kuery, @@ -511,9 +530,8 @@ export const serviceInstancesComparisonStatisticsRoute = createRoute({ comparisonEnd, serviceNodeIds, numBuckets, - } = context.params.query; - const latencyAggregationType = (context.params.query - .latencyAggregationType as unknown) as LatencyAggregationType; + latencyAggregationType, + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -535,7 +553,7 @@ export const serviceInstancesComparisonStatisticsRoute = createRoute({ }, }); -export const serviceDependenciesRoute = createRoute({ +const serviceDependenciesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/dependencies', params: t.type({ path: t.type({ @@ -552,11 +570,11 @@ export const serviceDependenciesRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - - const { serviceName } = context.params.path; - const { environment, numBuckets } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + const { environment, numBuckets } = params.query; const serviceDependencies = await getServiceDependencies({ serviceName, @@ -569,7 +587,7 @@ export const serviceDependenciesRoute = createRoute({ }, }); -export const serviceProfilingTimelineRoute = createRoute({ +const serviceProfilingTimelineRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/profiling/timeline', params: t.type({ path: t.type({ @@ -580,13 +598,13 @@ export const serviceProfilingTimelineRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; const { path: { serviceName }, query: { environment, kuery }, - } = context.params; + } = params; const profilingTimeline = await getServiceProfilingTimeline({ kuery, @@ -599,7 +617,7 @@ export const serviceProfilingTimelineRoute = createRoute({ }, }); -export const serviceProfilingStatisticsRoute = createRoute({ +const serviceProfilingStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/profiling/statistics', params: t.type({ path: t.type({ @@ -625,13 +643,15 @@ export const serviceProfilingStatisticsRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { params, logger } = resources; const { path: { serviceName }, query: { environment, kuery, valueType }, - } = context.params; + } = params; return getServiceProfilingStatistics({ kuery, @@ -639,7 +659,25 @@ export const serviceProfilingStatisticsRoute = createRoute({ environment, valueType, setup, - logger: context.logger, + logger, }); }, }); + +export const serviceRouteRepository = createApmServerRouteRepository() + .add(servicesRoute) + .add(serviceMetadataDetailsRoute) + .add(serviceMetadataIconsRoute) + .add(serviceAgentNameRoute) + .add(serviceTransactionTypesRoute) + .add(serviceNodeMetadataRoute) + .add(serviceAnnotationsRoute) + .add(serviceAnnotationsCreateRoute) + .add(serviceErrorGroupsPrimaryStatisticsRoute) + .add(serviceErrorGroupsComparisonStatisticsRoute) + .add(serviceThroughputRoute) + .add(serviceInstancesPrimaryStatisticsRoute) + .add(serviceInstancesComparisonStatisticsRoute) + .add(serviceDependenciesRoute) + .add(serviceProfilingTimelineRoute) + .add(serviceProfilingStatisticsRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 31e8d6cc1e9f0..111e0a18c8608 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -16,7 +16,7 @@ import { findExactConfiguration } from '../../lib/settings/agent_configuration/f import { listConfigurations } from '../../lib/settings/agent_configuration/list_configurations'; import { getEnvironments } from '../../lib/settings/agent_configuration/get_environments'; import { deleteConfiguration } from '../../lib/settings/agent_configuration/delete_configuration'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; import { getAgentNameByService } from '../../lib/settings/agent_configuration/get_agent_name_by_service'; import { markAppliedByAgent } from '../../lib/settings/agent_configuration/mark_applied_by_agent'; import { @@ -24,34 +24,37 @@ import { agentConfigurationIntakeRt, } from '../../../common/agent_configuration/runtime_types/agent_configuration_intake_rt'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; // get list of configurations -export const agentConfigurationRoute = createRoute({ +const agentConfigurationRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const configurations = await listConfigurations({ setup }); return { configurations }; }, }); // get a single configuration -export const getSingleAgentConfigurationRoute = createRoute({ +const getSingleAgentConfigurationRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/view', params: t.partial({ query: serviceRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { name, environment } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + + const { name, environment } = params.query; const service = { name, environment }; const config = await findExactConfiguration({ service, setup }); if (!config) { - context.logger.info( + logger.info( `Config was not found for ${service.name}/${service.environment}` ); @@ -63,7 +66,7 @@ export const getSingleAgentConfigurationRoute = createRoute({ }); // delete configuration -export const deleteAgentConfigurationRoute = createRoute({ +const deleteAgentConfigurationRoute = createApmServerRoute({ endpoint: 'DELETE /api/apm/settings/agent-configuration', options: { tags: ['access:apm', 'access:apm_write'], @@ -73,20 +76,22 @@ export const deleteAgentConfigurationRoute = createRoute({ service: serviceRt, }), }), - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { service } = context.params.body; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + + const { service } = params.body; const config = await findExactConfiguration({ service, setup }); if (!config) { - context.logger.info( + logger.info( `Config was not found for ${service.name}/${service.environment}` ); throw Boom.notFound(); } - context.logger.info( + logger.info( `Deleting config ${service.name}/${service.environment} (${config._id})` ); @@ -98,7 +103,7 @@ export const deleteAgentConfigurationRoute = createRoute({ }); // create/update configuration -export const createOrUpdateAgentConfigurationRoute = createRoute({ +const createOrUpdateAgentConfigurationRoute = createApmServerRoute({ endpoint: 'PUT /api/apm/settings/agent-configuration', options: { tags: ['access:apm', 'access:apm_write'], @@ -107,9 +112,10 @@ export const createOrUpdateAgentConfigurationRoute = createRoute({ t.partial({ query: t.partial({ overwrite: toBooleanRt }) }), t.type({ body: agentConfigurationIntakeRt }), ]), - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { body, query } = context.params; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + const { body, query } = params; // if the config already exists, it is fetched and updated // this is to avoid creating two configs with identical service params @@ -125,13 +131,13 @@ export const createOrUpdateAgentConfigurationRoute = createRoute({ ); } - context.logger.info( + logger.info( `${config ? 'Updating' : 'Creating'} config ${body.service.name}/${ body.service.environment }` ); - return await createOrUpdateConfiguration({ + await createOrUpdateConfiguration({ configurationId: config?._id, configurationIntake: body, setup, @@ -147,35 +153,35 @@ const searchParamsRt = t.intersection([ export type AgentConfigSearchParams = t.TypeOf; // Lookup single configuration (used by APM Server) -export const agentConfigurationSearchRoute = createRoute({ +const agentConfigurationSearchRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/agent-configuration/search', params: t.type({ body: searchParamsRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { params, logger } = resources; + const { service, etag, mark_as_applied_by_agent: markAsAppliedByAgent, - } = context.params.body; + } = params.body; - const setup = await setupRequest(context, request); + const setup = await setupRequest(resources); const config = await searchConfigurations({ service, setup, }); if (!config) { - context.logger.debug( + logger.debug( `[Central configuration] Config was not found for ${service.name}/${service.environment}` ); throw Boom.notFound(); } - context.logger.info( - `Config was found for ${service.name}/${service.environment}` - ); + logger.info(`Config was found for ${service.name}/${service.environment}`); // update `applied_by_agent` field // when `markAsAppliedByAgent` is true (Jaeger agent doesn't have etags) @@ -197,11 +203,11 @@ export const agentConfigurationSearchRoute = createRoute({ */ // get list of services -export const listAgentConfigurationServicesRoute = createRoute({ +const listAgentConfigurationServicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/services', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -215,15 +221,17 @@ export const listAgentConfigurationServicesRoute = createRoute({ }); // get environments for service -export const listAgentConfigurationEnvironmentsRoute = createRoute({ +const listAgentConfigurationEnvironmentsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/environments', params: t.partial({ query: t.partial({ serviceName: t.string }), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { serviceName } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -239,16 +247,27 @@ export const listAgentConfigurationEnvironmentsRoute = createRoute({ }); // get agentName for service -export const agentConfigurationAgentNameRoute = createRoute({ +const agentConfigurationAgentNameRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/agent-configuration/agent_name', params: t.type({ query: t.type({ serviceName: t.string }), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.query; const agentName = await getAgentNameByService({ serviceName, setup }); return { agentName }; }, }); + +export const agentConfigurationRouteRepository = createApmServerRouteRepository() + .add(agentConfigurationRoute) + .add(getSingleAgentConfigurationRoute) + .add(deleteAgentConfigurationRoute) + .add(createOrUpdateAgentConfigurationRoute) + .add(agentConfigurationSearchRoute) + .add(listAgentConfigurationServicesRoute) + .add(listAgentConfigurationEnvironmentsRoute) + .add(agentConfigurationAgentNameRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index de7f35c4081bc..98467e1a4a0dd 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; import Boom from '@hapi/boom'; import { isActivePlatinumLicense } from '../../../common/license_check'; import { ML_ERRORS } from '../../../common/anomaly_detection'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; import { getAnomalyDetectionJobs } from '../../lib/anomaly_detection/get_anomaly_detection_jobs'; import { createAnomalyDetectionJobs } from '../../lib/anomaly_detection/create_anomaly_detection_jobs'; import { setupRequest } from '../../lib/helpers/setup_request'; @@ -18,15 +18,17 @@ import { hasLegacyJobs } from '../../lib/anomaly_detection/has_legacy_jobs'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; import { notifyFeatureUsage } from '../../feature'; import { withApmSpan } from '../../utils/with_apm_span'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; // get ML anomaly detection jobs for each environment -export const anomalyDetectionJobsRoute = createRoute({ +const anomalyDetectionJobsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/anomaly-detection/jobs', options: { tags: ['access:apm', 'access:ml:canGetJobs'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { context, logger } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); @@ -34,7 +36,7 @@ export const anomalyDetectionJobsRoute = createRoute({ const [jobs, legacyJobs] = await withApmSpan('get_available_ml_jobs', () => Promise.all([ - getAnomalyDetectionJobs(setup, context.logger), + getAnomalyDetectionJobs(setup, logger), hasLegacyJobs(setup), ]) ); @@ -47,7 +49,7 @@ export const anomalyDetectionJobsRoute = createRoute({ }); // create new ML anomaly detection jobs for each given environment -export const createAnomalyDetectionJobsRoute = createRoute({ +const createAnomalyDetectionJobsRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/anomaly-detection/jobs', options: { tags: ['access:apm', 'access:apm_write', 'access:ml:canCreateJob'], @@ -57,15 +59,17 @@ export const createAnomalyDetectionJobsRoute = createRoute({ environments: t.array(t.string), }), }), - handler: async ({ context, request }) => { - const { environments } = context.params.body; - const setup = await setupRequest(context, request); + handler: async (resources) => { + const { params, context, logger } = resources; + const { environments } = params.body; + + const setup = await setupRequest(resources); if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); } - await createAnomalyDetectionJobs(setup, environments, context.logger); + await createAnomalyDetectionJobs(setup, environments, logger); notifyFeatureUsage({ licensingPlugin: context.licensing, @@ -77,11 +81,11 @@ export const createAnomalyDetectionJobsRoute = createRoute({ }); // get all available environments to create anomaly detection jobs for -export const anomalyDetectionEnvironmentsRoute = createRoute({ +const anomalyDetectionEnvironmentsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/anomaly-detection/environments', options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -96,3 +100,8 @@ export const anomalyDetectionEnvironmentsRoute = createRoute({ return { environments }; }, }); + +export const anomalyDetectionRouteRepository = createApmServerRouteRepository() + .add(anomalyDetectionJobsRoute) + .add(createAnomalyDetectionJobsRoute) + .add(anomalyDetectionEnvironmentsRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/apm_indices.ts b/x-pack/plugins/apm/server/routes/settings/apm_indices.ts index 91057c97579e4..003471aa89f39 100644 --- a/x-pack/plugins/apm/server/routes/settings/apm_indices.ts +++ b/x-pack/plugins/apm/server/routes/settings/apm_indices.ts @@ -6,7 +6,8 @@ */ import * as t from 'io-ts'; -import { createRoute } from '../create_route'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; +import { createApmServerRoute } from '../create_apm_server_route'; import { getApmIndices, getApmIndexSettings, @@ -14,29 +15,30 @@ import { import { saveApmIndices } from '../../lib/settings/apm_indices/save_apm_indices'; // get list of apm indices and values -export const apmIndexSettingsRoute = createRoute({ +const apmIndexSettingsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/apm-index-settings', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { - const apmIndexSettings = await getApmIndexSettings({ context }); + handler: async ({ config, context }) => { + const apmIndexSettings = await getApmIndexSettings({ config, context }); return { apmIndexSettings }; }, }); // get apm indices configuration object -export const apmIndicesRoute = createRoute({ +const apmIndicesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/apm-indices', options: { tags: ['access:apm'] }, - handler: async ({ context }) => { + handler: async (resources) => { + const { context, config } = resources; return await getApmIndices({ savedObjectsClient: context.core.savedObjects.client, - config: context.config, + config, }); }, }); // save ui indices -export const saveApmIndicesRoute = createRoute({ +const saveApmIndicesRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/apm-indices/save', options: { tags: ['access:apm', 'access:apm_write'], @@ -53,9 +55,15 @@ export const saveApmIndicesRoute = createRoute({ /* eslint-enable @typescript-eslint/naming-convention */ }), }), - handler: async ({ context }) => { - const { body } = context.params; + handler: async (resources) => { + const { params, context } = resources; + const { body } = params; const savedObjectsClient = context.core.savedObjects.client; return await saveApmIndices(savedObjectsClient, body); }, }); + +export const apmIndicesRouteRepository = createApmServerRouteRepository() + .add(apmIndexSettingsRoute) + .add(apmIndicesRoute) + .add(saveApmIndicesRoute); diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts index a6ab553f09419..c9c5d236c14f9 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -21,35 +21,40 @@ import { import { deleteCustomLink } from '../../lib/settings/custom_link/delete_custom_link'; import { getTransaction } from '../../lib/settings/custom_link/get_transaction'; import { listCustomLinks } from '../../lib/settings/custom_link/list_custom_links'; -import { createRoute } from '../create_route'; +import { createApmServerRoute } from '../create_apm_server_route'; +import { createApmServerRouteRepository } from '../create_apm_server_route_repository'; -export const customLinkTransactionRoute = createRoute({ +const customLinkTransactionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/custom_links/transaction', options: { tags: ['access:apm'] }, params: t.partial({ query: filterOptionsRt, }), - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { query } = context.params; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { query } = params; // picks only the items listed in FILTER_OPTIONS const filters = pick(query, FILTER_OPTIONS); return await getTransaction({ setup, filters }); }, }); -export const listCustomLinksRoute = createRoute({ +const listCustomLinksRoute = createApmServerRoute({ endpoint: 'GET /api/apm/settings/custom_links', options: { tags: ['access:apm'] }, params: t.partial({ query: filterOptionsRt, }), - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const { query } = context.params; + const setup = await setupRequest(resources); + + const { query } = params; + // picks only the items listed in FILTER_OPTIONS const filters = pick(query, FILTER_OPTIONS); const customLinks = await listCustomLinks({ setup, filters }); @@ -57,29 +62,30 @@ export const listCustomLinksRoute = createRoute({ }, }); -export const createCustomLinkRoute = createRoute({ +const createCustomLinkRoute = createApmServerRoute({ endpoint: 'POST /api/apm/settings/custom_links', params: t.type({ body: payloadRt, }), options: { tags: ['access:apm', 'access:apm_write'] }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const customLink = context.params.body; - const res = await createOrUpdateCustomLink({ customLink, setup }); + const setup = await setupRequest(resources); + const customLink = params.body; notifyFeatureUsage({ licensingPlugin: context.licensing, featureName: 'customLinks', }); - return res; + + await createOrUpdateCustomLink({ customLink, setup }); }, }); -export const updateCustomLinkRoute = createRoute({ +const updateCustomLinkRoute = createApmServerRoute({ endpoint: 'PUT /api/apm/settings/custom_links/{id}', params: t.type({ path: t.type({ @@ -90,23 +96,26 @@ export const updateCustomLinkRoute = createRoute({ options: { tags: ['access:apm', 'access:apm_write'], }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { params, context } = resources; + if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const { id } = context.params.path; - const customLink = context.params.body; - const res = await createOrUpdateCustomLink({ + const setup = await setupRequest(resources); + + const { id } = params.path; + const customLink = params.body; + + await createOrUpdateCustomLink({ customLinkId: id, customLink, setup, }); - return res; }, }); -export const deleteCustomLinkRoute = createRoute({ +const deleteCustomLinkRoute = createApmServerRoute({ endpoint: 'DELETE /api/apm/settings/custom_links/{id}', params: t.type({ path: t.type({ @@ -116,12 +125,14 @@ export const deleteCustomLinkRoute = createRoute({ options: { tags: ['access:apm', 'access:apm_write'], }, - handler: async ({ context, request }) => { + handler: async (resources) => { + const { context, params } = resources; + if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); } - const setup = await setupRequest(context, request); - const { id } = context.params.path; + const setup = await setupRequest(resources); + const { id } = params.path; const res = await deleteCustomLink({ customLinkId: id, setup, @@ -129,3 +140,10 @@ export const deleteCustomLinkRoute = createRoute({ return res; }, }); + +export const customLinkRouteRepository = createApmServerRouteRepository() + .add(customLinkTransactionRoute) + .add(listCustomLinksRoute) + .add(createCustomLinkRoute) + .add(updateCustomLinkRoute) + .add(deleteCustomLinkRoute); diff --git a/x-pack/plugins/apm/server/routes/traces.ts b/x-pack/plugins/apm/server/routes/traces.ts index 6287ffbf0c751..dd392982b02fd 100644 --- a/x-pack/plugins/apm/server/routes/traces.ts +++ b/x-pack/plugins/apm/server/routes/traces.ts @@ -9,20 +9,22 @@ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getTrace } from '../lib/traces/get_trace'; import { getTransactionGroupList } from '../lib/transaction_groups'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; import { environmentRt, kueryRt, rangeRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { getRootTransactionByTraceId } from '../lib/transactions/get_transaction_by_trace'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; -export const tracesRoute = createRoute({ +const tracesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/traces', params: t.type({ query: t.intersection([environmentRt, kueryRt, rangeRt]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { environment, kuery } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { environment, kuery } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); @@ -34,7 +36,7 @@ export const tracesRoute = createRoute({ }, }); -export const tracesByIdRoute = createRoute({ +const tracesByIdRoute = createApmServerRoute({ endpoint: 'GET /api/apm/traces/{traceId}', params: t.type({ path: t.type({ @@ -43,13 +45,16 @@ export const tracesByIdRoute = createRoute({ query: rangeRt, }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - return getTrace(context.params.path.traceId, setup); + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { traceId } = params.path; + return getTrace(traceId, setup); }, }); -export const rootTransactionByTraceIdRoute = createRoute({ +const rootTransactionByTraceIdRoute = createApmServerRoute({ endpoint: 'GET /api/apm/traces/{traceId}/root_transaction', params: t.type({ path: t.type({ @@ -57,9 +62,15 @@ export const rootTransactionByTraceIdRoute = createRoute({ }), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const { traceId } = context.params.path; - const setup = await setupRequest(context, request); + handler: async (resources) => { + const { params } = resources; + const { traceId } = params.path; + const setup = await setupRequest(resources); return getRootTransactionByTraceId(traceId, setup); }, }); + +export const traceRouteRepository = createApmServerRouteRepository() + .add(tracesByIdRoute) + .add(tracesRoute) + .add(rootTransactionByTraceIdRoute); diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index f3424a252e409..ebca374db86d7 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -5,12 +5,12 @@ * 2.0. */ +import { jsonRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; import { LatencyAggregationType, latencyAggregationTypeRt, } from '../../common/latency_aggregation_types'; -import { jsonRt } from '../../common/runtime_types/json_rt'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; @@ -23,7 +23,8 @@ import { getLatencyPeriods } from '../lib/transactions/get_latency_charts'; import { getThroughputCharts } from '../lib/transactions/get_throughput_charts'; import { getTransactionGroupList } from '../lib/transaction_groups'; import { getErrorRatePeriods } from '../lib/transaction_groups/get_error_rate'; -import { createRoute } from './create_route'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { comparisonRangeRt, environmentRt, @@ -35,7 +36,7 @@ import { * Returns a list of transactions grouped by name * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/primary_statistics/ */ -export const transactionGroupsRoute = createRoute({ +const transactionGroupsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups', params: t.type({ path: t.type({ @@ -49,10 +50,11 @@ export const transactionGroupsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { environment, kuery, transactionType } = context.params.query; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; + const { environment, kuery, transactionType } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -72,7 +74,7 @@ export const transactionGroupsRoute = createRoute({ }, }); -export const transactionGroupsPrimaryStatisticsRoute = createRoute({ +const transactionGroupsPrimaryStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics', params: t.type({ @@ -90,8 +92,9 @@ export const transactionGroupsPrimaryStatisticsRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const { params } = resources; + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -100,7 +103,7 @@ export const transactionGroupsPrimaryStatisticsRoute = createRoute({ const { path: { serviceName }, query: { environment, kuery, latencyAggregationType, transactionType }, - } = context.params; + } = params; return getServiceTransactionGroups({ environment, @@ -109,12 +112,12 @@ export const transactionGroupsPrimaryStatisticsRoute = createRoute({ serviceName, searchAggregatedTransactions, transactionType, - latencyAggregationType: latencyAggregationType as LatencyAggregationType, + latencyAggregationType, }); }, }); -export const transactionGroupsComparisonStatisticsRoute = createRoute({ +const transactionGroupsComparisonStatisticsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics', params: t.type({ @@ -135,13 +138,15 @@ export const transactionGroupsComparisonStatisticsRoute = createRoute({ options: { tags: ['access:apm'], }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); + handler: async (resources) => { + const setup = await setupRequest(resources); const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); + const { params } = resources; + const { path: { serviceName }, query: { @@ -154,7 +159,7 @@ export const transactionGroupsComparisonStatisticsRoute = createRoute({ comparisonStart, comparisonEnd, }, - } = context.params; + } = params; return await getServiceTransactionGroupComparisonStatisticsPeriods({ environment, @@ -165,14 +170,14 @@ export const transactionGroupsComparisonStatisticsRoute = createRoute({ searchAggregatedTransactions, transactionType, numBuckets, - latencyAggregationType: latencyAggregationType as LatencyAggregationType, + latencyAggregationType, comparisonStart, comparisonEnd, }); }, }); -export const transactionLatencyChartsRoute = createRoute({ +const transactionLatencyChartsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/latency', params: t.type({ path: t.type({ @@ -188,10 +193,11 @@ export const transactionLatencyChartsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const logger = context.logger; - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params, logger } = resources; + + const { serviceName } = params.path; const { environment, kuery, @@ -200,7 +206,7 @@ export const transactionLatencyChartsRoute = createRoute({ latencyAggregationType, comparisonStart, comparisonEnd, - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -242,7 +248,7 @@ export const transactionLatencyChartsRoute = createRoute({ }, }); -export const transactionThroughputChartsRoute = createRoute({ +const transactionThroughputChartsRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/throughput', params: t.type({ @@ -258,15 +264,17 @@ export const transactionThroughputChartsRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { serviceName } = params.path; const { environment, kuery, transactionType, transactionName, - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -284,7 +292,7 @@ export const transactionThroughputChartsRoute = createRoute({ }, }); -export const transactionChartsDistributionRoute = createRoute({ +const transactionChartsDistributionRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/distribution', params: t.type({ @@ -306,9 +314,10 @@ export const transactionChartsDistributionRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { serviceName } = params.path; const { environment, kuery, @@ -316,7 +325,7 @@ export const transactionChartsDistributionRoute = createRoute({ transactionName, transactionId = '', traceId = '', - } = context.params.query; + } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -336,7 +345,7 @@ export const transactionChartsDistributionRoute = createRoute({ }, }); -export const transactionChartsBreakdownRoute = createRoute({ +const transactionChartsBreakdownRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transaction/charts/breakdown', params: t.type({ path: t.type({ @@ -351,15 +360,17 @@ export const transactionChartsBreakdownRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + + const { serviceName } = params.path; const { environment, kuery, transactionName, transactionType, - } = context.params.query; + } = params.query; return getTransactionBreakdown({ environment, @@ -372,7 +383,7 @@ export const transactionChartsBreakdownRoute = createRoute({ }, }); -export const transactionChartsErrorRateRoute = createRoute({ +const transactionChartsErrorRateRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/error_rate', params: t.type({ @@ -386,9 +397,10 @@ export const transactionChartsErrorRateRoute = createRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; + handler: async (resources) => { + const setup = await setupRequest(resources); + + const { params } = resources; const { serviceName } = params.path; const { environment, @@ -416,3 +428,13 @@ export const transactionChartsErrorRateRoute = createRoute({ }); }, }); + +export const transactionRouteRepository = createApmServerRouteRepository() + .add(transactionGroupsRoute) + .add(transactionGroupsPrimaryStatisticsRoute) + .add(transactionGroupsComparisonStatisticsRoute) + .add(transactionLatencyChartsRoute) + .add(transactionThroughputChartsRoute) + .add(transactionChartsDistributionRoute) + .add(transactionChartsBreakdownRoute) + .add(transactionChartsErrorRateRoute); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 3ba24b4ed5268..0fec88a4326c3 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -5,27 +5,19 @@ * 2.0. */ -import t, { Encode, Encoder } from 'io-ts'; import { CoreSetup, - KibanaRequest, RequestHandlerContext, Logger, + KibanaRequest, + CoreStart, } from 'src/core/server'; -import { Observable } from 'rxjs'; -import { RequiredKeys, DeepPartial } from 'utility-types'; -import { SpacesPluginStart } from '../../../spaces/server'; -import { ObservabilityPluginSetup } from '../../../observability/server'; import { LicensingApiRequestHandlerContext } from '../../../licensing/server'; -import { SecurityPluginSetup } from '../../../security/server'; -import { MlPluginSetup } from '../../../ml/server'; -import { FetchOptions } from '../../common/fetch_options'; import { APMConfig } from '..'; +import { APMPluginDependencies } from '../types'; -export type HandlerReturn = Record; - -interface InspectQueryParam { - query: { _inspect: boolean }; +export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { + licensing: LicensingApiRequestHandlerContext; } export type InspectResponse = Array<{ @@ -36,141 +28,53 @@ export type InspectResponse = Array<{ esError: Error; }>; -export interface RouteParams { - path?: Record; - query?: Record; - body?: any; +export interface APMRouteCreateOptions { + options: { + tags: Array< + | 'access:apm' + | 'access:apm_write' + | 'access:ml:canGetJobs' + | 'access:ml:canCreateJob' + >; + }; } -type WithoutIncompatibleMethods = Omit< - T, - 'encode' | 'asEncoder' -> & { encode: Encode; asEncoder: () => Encoder }; - -export type RouteParamsRT = WithoutIncompatibleMethods>; - -export type RouteHandler< - TParamsRT extends RouteParamsRT | undefined, - TReturn extends HandlerReturn -> = (kibanaContext: { - context: APMRequestHandlerContext< - (TParamsRT extends RouteParamsRT ? t.TypeOf : {}) & - InspectQueryParam - >; +export interface APMRouteHandlerResources { request: KibanaRequest; -}) => Promise; - -interface RouteOptions { - tags: Array< - | 'access:apm' - | 'access:apm_write' - | 'access:ml:canGetJobs' - | 'access:ml:canCreateJob' - >; -} - -export interface Route< - TEndpoint extends string, - TRouteParamsRT extends RouteParamsRT | undefined, - TReturn extends HandlerReturn -> { - endpoint: TEndpoint; - options: RouteOptions; - params?: TRouteParamsRT; - handler: RouteHandler; -} - -/** - * @internal - */ -export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { - licensing: LicensingApiRequestHandlerContext; -} - -export type APMRequestHandlerContext< - TRouteParams = {} -> = ApmPluginRequestHandlerContext & { - params: TRouteParams & InspectQueryParam; + context: ApmPluginRequestHandlerContext; + params: { + query: { + _inspect: boolean; + }; + }; config: APMConfig; logger: Logger; - plugins: { - spaces?: SpacesPluginStart; - observability?: ObservabilityPluginSetup; - security?: SecurityPluginSetup; - ml?: MlPluginSetup; + core: { + setup: CoreSetup; + start: () => Promise; }; -}; - -export interface RouteState { - [endpoint: string]: { - params?: RouteParams; - ret: any; + plugins: { + [key in keyof APMPluginDependencies]: { + setup: Required[key]['setup']; + start: () => Promise[key]['start']>; + }; }; } -export interface ServerAPI { - _S: TRouteState; - add< - TEndpoint extends string, - TReturn extends HandlerReturn, - TRouteParamsRT extends RouteParamsRT | undefined = undefined - >( - route: - | Route - | ((core: CoreSetup) => Route) - ): ServerAPI< - TRouteState & - { - [key in TEndpoint]: { - params: TRouteParamsRT; - ret: TReturn & { _inspect?: InspectResponse }; - }; - } - >; - init: ( - core: CoreSetup, - context: { - config$: Observable; - logger: Logger; - plugins: { - observability?: ObservabilityPluginSetup; - security?: SecurityPluginSetup; - ml?: MlPluginSetup; - }; - } - ) => void; -} - -type MaybeOptional }> = RequiredKeys< - T['params'] -> extends never - ? { params?: T['params'] } - : { params: T['params'] }; - -export type MaybeParams< - TRouteState, - TEndpoint extends keyof TRouteState & string -> = TRouteState[TEndpoint] extends { params: t.Any } - ? MaybeOptional<{ - params: t.OutputOf & - DeepPartial; - }> - : {}; - -export type Client< - TRouteState, - TOptions extends { abortable: boolean } = { abortable: true } -> = ( - options: Omit< - FetchOptions, - 'query' | 'body' | 'pathname' | 'method' | 'signal' - > & { - forceCache?: boolean; - endpoint: TEndpoint; - } & MaybeParams & - (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) -) => Promise< - TRouteState[TEndpoint] extends { ret: any } - ? TRouteState[TEndpoint]['ret'] - : unknown ->; +// export type Client< +// TRouteState, +// TOptions extends { abortable: boolean } = { abortable: true } +// > = ( +// options: Omit< +// FetchOptions, +// 'query' | 'body' | 'pathname' | 'method' | 'signal' +// > & { +// forceCache?: boolean; +// endpoint: TEndpoint; +// } & MaybeParams & +// (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) +// ) => Promise< +// TRouteState[TEndpoint] extends { ret: any } +// ? TRouteState[TEndpoint]['ret'] +// : unknown +// >; diff --git a/x-pack/plugins/apm/server/types.ts b/x-pack/plugins/apm/server/types.ts new file mode 100644 index 0000000000000..cef9eaf2f4fc0 --- /dev/null +++ b/x-pack/plugins/apm/server/types.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ValuesType } from 'utility-types'; +import { Observable } from 'rxjs'; +import { CoreSetup, CoreStart, KibanaRequest } from 'kibana/server'; +import { + PluginSetup as DataPluginSetup, + PluginStart as DataPluginStart, +} from '../../../../src/plugins/data/server'; +import { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server'; +import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; +import { + HomeServerPluginSetup, + HomeServerPluginStart, +} from '../../../../src/plugins/home/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { ActionsPlugin } from '../../actions/server'; +import { AlertingPlugin } from '../../alerting/server'; +import { CloudSetup } from '../../cloud/server'; +import { + PluginSetupContract as FeaturesPluginSetup, + PluginStartContract as FeaturesPluginStart, +} from '../../features/server'; +import { + LicensingPluginSetup, + LicensingPluginStart, +} from '../../licensing/server'; +import { MlPluginSetup, MlPluginStart } from '../../ml/server'; +import { ObservabilityPluginSetup } from '../../observability/server'; +import { + SecurityPluginSetup, + SecurityPluginStart, +} from '../../security/server'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../task_manager/server'; +import { APMConfig } from '.'; +import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; +import { createApmEventClient } from './lib/helpers/create_es_client/create_apm_event_client'; +import { ApmPluginRequestHandlerContext } from './routes/typings'; + +export interface APMPluginSetup { + config$: Observable; + getApmIndices: () => ReturnType; + createApmEventClient: (params: { + debug?: boolean; + request: KibanaRequest; + context: ApmPluginRequestHandlerContext; + }) => Promise>; +} + +interface DependencyMap { + core: { + setup: CoreSetup; + start: CoreStart; + }; + spaces: { + setup: SpacesPluginSetup; + start: SpacesPluginStart; + }; + apmOss: { + setup: APMOSSPluginSetup; + start: undefined; + }; + home: { + setup: HomeServerPluginSetup; + start: HomeServerPluginStart; + }; + licensing: { + setup: LicensingPluginSetup; + start: LicensingPluginStart; + }; + cloud: { + setup: CloudSetup; + start: undefined; + }; + usageCollection: { + setup: UsageCollectionSetup; + start: undefined; + }; + taskManager: { + setup: TaskManagerSetupContract; + start: TaskManagerStartContract; + }; + alerting: { + setup: AlertingPlugin['setup']; + start: AlertingPlugin['start']; + }; + actions: { + setup: ActionsPlugin['setup']; + start: ActionsPlugin['start']; + }; + observability: { + setup: ObservabilityPluginSetup; + start: undefined; + }; + features: { + setup: FeaturesPluginSetup; + start: FeaturesPluginStart; + }; + security: { + setup: SecurityPluginSetup; + start: SecurityPluginStart; + }; + ml: { + setup: MlPluginSetup; + start: MlPluginStart; + }; + data: { + setup: DataPluginSetup; + start: DataPluginStart; + }; +} + +const requiredDependencies = [ + 'features', + 'apmOss', + 'data', + 'licensing', + 'triggersActionsUi', + 'embeddable', + 'infra', +] as const; + +const optionalDependencies = [ + 'spaces', + 'cloud', + 'usageCollection', + 'taskManager', + 'actions', + 'alerting', + 'observability', + 'security', + 'ml', + 'home', + 'maps', +] as const; + +type RequiredDependencies = Pick< + DependencyMap, + ValuesType & keyof DependencyMap +>; + +type OptionalDependencies = Partial< + Pick< + DependencyMap, + ValuesType & keyof DependencyMap + > +>; + +export type APMPluginDependencies = RequiredDependencies & OptionalDependencies; + +export type APMPluginSetupDependencies = { + [key in keyof APMPluginDependencies]: Required[key]['setup']; +}; + +export type APMPluginStartDependencies = { + [key in keyof APMPluginDependencies]: Required[key]['start']; +}; diff --git a/x-pack/test/apm_api_integration/common/apm_api_supertest.ts b/x-pack/test/apm_api_integration/common/apm_api_supertest.ts index 542982778dfff..ed104a6fdf064 100644 --- a/x-pack/test/apm_api_integration/common/apm_api_supertest.ts +++ b/x-pack/test/apm_api_integration/common/apm_api_supertest.ts @@ -8,24 +8,25 @@ import { format } from 'url'; import supertest from 'supertest'; import request from 'superagent'; -import { MaybeParams } from '../../../plugins/apm/server/routes/typings'; import { parseEndpoint } from '../../../plugins/apm/common/apm_api/parse_endpoint'; -import { APMAPI } from '../../../plugins/apm/server/routes/create_apm_api'; -import type { APIReturnType } from '../../../plugins/apm/public/services/rest/createCallApmApi'; +import type { + APIReturnType, + APIEndpoint, + APIClientRequestParamsOf, +} from '../../../plugins/apm/public/services/rest/createCallApmApi'; export function createApmApiSupertest(st: supertest.SuperTest) { - return async ( + return async ( options: { - endpoint: TPath; - } & MaybeParams + endpoint: TEndpoint; + } & APIClientRequestParamsOf & { params?: { query?: { _inspect?: boolean } } } ): Promise<{ status: number; - body: APIReturnType; + body: APIReturnType; }> => { const { endpoint } = options; - // @ts-expect-error - const params = 'params' in options ? options.params : {}; + const params = 'params' in options ? (options.params as Record) : {}; const { method, pathname } = parseEndpoint(endpoint, params?.path); const url = format({ pathname, query: params?.query }); diff --git a/x-pack/test/apm_api_integration/tests/inspect/inspect.ts b/x-pack/test/apm_api_integration/tests/inspect/inspect.ts index aae2e38e8ec8e..4f65808de820e 100644 --- a/x-pack/test/apm_api_integration/tests/inspect/inspect.ts +++ b/x-pack/test/apm_api_integration/tests/inspect/inspect.ts @@ -81,7 +81,6 @@ export default function customLinksTests({ getService }: FtrProviderContext) { it('for agent configs', async () => { const { status, body } = await supertestRead({ endpoint: 'GET /api/apm/settings/agent-configuration', - // @ts-expect-error params: { query: { _inspect: true, diff --git a/x-pack/test/apm_api_integration/tests/service_overview/instances_primary_statistics.ts b/x-pack/test/apm_api_integration/tests/service_overview/instances_primary_statistics.ts index aac92685a3c34..baa95eb56a126 100644 --- a/x-pack/test/apm_api_integration/tests/service_overview/instances_primary_statistics.ts +++ b/x-pack/test/apm_api_integration/tests/service_overview/instances_primary_statistics.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; import archives from '../../common/fixtures/es_archiver/archives_metadata'; import { registry } from '../../common/registry'; import { createApmApiSupertest } from '../../common/apm_api_supertest'; +import { LatencyAggregationType } from '../../../../plugins/apm/common/latency_aggregation_types'; export default function ApiTest({ getService }: FtrProviderContext) { const apmApiSupertest = createApmApiSupertest(getService('supertest')); @@ -31,7 +32,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { params: { path: { serviceName: 'opbeans-java' }, query: { - latencyAggregationType: 'avg', + latencyAggregationType: LatencyAggregationType.avg, start, end, transactionType: 'request', @@ -61,7 +62,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { params: { path: { serviceName: 'opbeans-java' }, query: { - latencyAggregationType: 'avg', + latencyAggregationType: LatencyAggregationType.avg, start, end, transactionType: 'request', @@ -130,7 +131,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { params: { path: { serviceName: 'opbeans-ruby' }, query: { - latencyAggregationType: 'avg', + latencyAggregationType: LatencyAggregationType.avg, start, end, transactionType: 'request', diff --git a/yarn.lock b/yarn.lock index 546fe439f56dd..e1118cf0b74fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2680,6 +2680,10 @@ version "0.0.0" uid "" +"@kbn/io-ts-utils@link:packages/kbn-io-ts-utils": + version "0.0.0" + uid "" + "@kbn/legacy-logging@link:packages/kbn-legacy-logging": version "0.0.0" uid "" @@ -2712,6 +2716,10 @@ version "0.0.0" uid "" +"@kbn/server-route-repository@link:packages/kbn-server-route-repository": + version "0.0.0" + uid "" + "@kbn/std@link:packages/kbn-std": version "0.0.0" uid "" From 92da416341fa8377da893c44e16740e4a5f4c1d5 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 8 Apr 2021 16:04:30 +0200 Subject: [PATCH 12/22] Don't trigger auto-refresh until previous refresh completes (#93410) (#96547) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...n-plugins-data-public.autorefreshdonefn.md | 11 + .../kibana-plugin-plugins-data-public.md | 3 + ...a-public.waituntilnextsessioncompletes_.md | 25 +++ ...ic.waituntilnextsessioncompletesoptions.md | 20 ++ ...nextsessioncompletesoptions.waitforidle.md | 13 ++ .../public/application/dashboard_app.tsx | 24 +- src/plugins/data/README.mdx | 159 ++++++++------ src/plugins/data/public/index.ts | 3 + src/plugins/data/public/public.api.md | 47 ++-- .../data/public/query/timefilter/index.ts | 2 +- .../timefilter/lib/auto_refresh_loop.test.ts | 205 ++++++++++++++++++ .../query/timefilter/lib/auto_refresh_loop.ts | 80 +++++++ .../query/timefilter/timefilter.test.ts | 45 +++- .../public/query/timefilter/timefilter.ts | 30 +-- .../timefilter/timefilter_service.mock.ts | 2 +- src/plugins/data/public/search/index.ts | 2 + .../data/public/search/session/index.ts | 4 + .../search/session/session_helpers.test.ts | 88 ++++++++ .../public/search/session/session_helpers.ts | 48 ++++ .../public/application/angular/discover.js | 26 ++- src/plugins/expressions/public/loader.ts | 7 +- .../public/embeddable/visualize_embeddable.ts | 8 +- .../components/visualize_top_nav.tsx | 8 +- .../lens/public/app_plugin/app.test.tsx | 6 +- x-pack/plugins/lens/public/app_plugin/app.tsx | 15 +- 25 files changed, 755 insertions(+), 126 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.autorefreshdonefn.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md create mode 100644 src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.test.ts create mode 100644 src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.ts create mode 100644 src/plugins/data/public/search/session/session_helpers.test.ts create mode 100644 src/plugins/data/public/search/session/session_helpers.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.autorefreshdonefn.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.autorefreshdonefn.md new file mode 100644 index 0000000000000..a5694ea2d1af9 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.autorefreshdonefn.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AutoRefreshDoneFn](./kibana-plugin-plugins-data-public.autorefreshdonefn.md) + +## AutoRefreshDoneFn type + +Signature: + +```typescript +export declare type AutoRefreshDoneFn = () => void; +``` 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 d2e7ef9db05e8..4429f45f55645 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 @@ -47,6 +47,7 @@ | [getSearchParamsFromRequest(searchRequest, dependencies)](./kibana-plugin-plugins-data-public.getsearchparamsfromrequest.md) | | | [getTime(indexPattern, timeRange, options)](./kibana-plugin-plugins-data-public.gettime.md) | | | [plugin(initializerContext)](./kibana-plugin-plugins-data-public.plugin.md) | | +| [waitUntilNextSessionCompletes$(sessionService, { waitForIdle })](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) | Creates an observable that emits when next search session completes. This utility is helpful to use in the application to delay some tasks until next session completes. | ## Interfaces @@ -92,6 +93,7 @@ | [SearchInterceptorDeps](./kibana-plugin-plugins-data-public.searchinterceptordeps.md) | | | [SearchSessionInfoProvider](./kibana-plugin-plugins-data-public.searchsessioninfoprovider.md) | Provide info about current search session to be stored in the Search Session saved object | | [SearchSourceFields](./kibana-plugin-plugins-data-public.searchsourcefields.md) | search source fields | +| [WaitUntilNextSessionCompletesOptions](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md) | Options for [waitUntilNextSessionCompletes$()](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) | ## Variables @@ -141,6 +143,7 @@ | [AggParam](./kibana-plugin-plugins-data-public.aggparam.md) | | | [AggsStart](./kibana-plugin-plugins-data-public.aggsstart.md) | AggsStart represents the actual external contract as AggsCommonStart is only used internally. The difference is that AggsStart includes the typings for the registry with initialized agg types. | | [AutocompleteStart](./kibana-plugin-plugins-data-public.autocompletestart.md) | \* | +| [AutoRefreshDoneFn](./kibana-plugin-plugins-data-public.autorefreshdonefn.md) | | | [CustomFilter](./kibana-plugin-plugins-data-public.customfilter.md) | | | [EsaggsExpressionFunctionDefinition](./kibana-plugin-plugins-data-public.esaggsexpressionfunctiondefinition.md) | | | [EsdslExpressionFunctionDefinition](./kibana-plugin-plugins-data-public.esdslexpressionfunctiondefinition.md) | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md new file mode 100644 index 0000000000000..a4b294fb1decd --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [waitUntilNextSessionCompletes$](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) + +## waitUntilNextSessionCompletes$() function + +Creates an observable that emits when next search session completes. This utility is helpful to use in the application to delay some tasks until next session completes. + +Signature: + +```typescript +export declare function waitUntilNextSessionCompletes$(sessionService: ISessionService, { waitForIdle }?: WaitUntilNextSessionCompletesOptions): import("rxjs").Observable; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| sessionService | ISessionService | [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) | +| { waitForIdle } | WaitUntilNextSessionCompletesOptions | | + +Returns: + +`import("rxjs").Observable` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md new file mode 100644 index 0000000000000..d575722a22453 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [WaitUntilNextSessionCompletesOptions](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md) + +## WaitUntilNextSessionCompletesOptions interface + +Options for [waitUntilNextSessionCompletes$()](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletes_.md) + +Signature: + +```typescript +export interface WaitUntilNextSessionCompletesOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [waitForIdle](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md) | number | For how long to wait between session state transitions before considering that session completed | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md new file mode 100644 index 0000000000000..60d3df7783852 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [WaitUntilNextSessionCompletesOptions](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.md) > [waitForIdle](./kibana-plugin-plugins-data-public.waituntilnextsessioncompletesoptions.waitforidle.md) + +## WaitUntilNextSessionCompletesOptions.waitForIdle property + +For how long to wait between session state transitions before considering that session completed + +Signature: + +```typescript +waitForIdle?: number; +``` diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index 3d6f08f321977..e7e2ccfd46b9c 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -10,7 +10,7 @@ import { History } from 'history'; import { merge, Subject, Subscription } from 'rxjs'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { debounceTime, tap } from 'rxjs/operators'; +import { debounceTime, finalize, switchMap, tap } from 'rxjs/operators'; import { useKibana } from '../../../kibana_react/public'; import { DashboardConstants } from '../dashboard_constants'; import { DashboardTopNav } from './top_nav/dashboard_top_nav'; @@ -30,7 +30,7 @@ import { useSavedDashboard, } from './hooks'; -import { IndexPattern } from '../services/data'; +import { IndexPattern, waitUntilNextSessionCompletes$ } from '../services/data'; import { EmbeddableRenderer } from '../services/embeddable'; import { DashboardContainerInput } from '.'; import { leaveConfirmStrings } from '../dashboard_strings'; @@ -209,14 +209,26 @@ export function DashboardApp({ ); subscriptions.add( - merge( - data.query.timefilter.timefilter.getAutoRefreshFetch$(), - searchSessionIdQuery$ - ).subscribe(() => { + searchSessionIdQuery$.subscribe(() => { triggerRefresh$.next({ force: true }); }) ); + subscriptions.add( + data.query.timefilter.timefilter + .getAutoRefreshFetch$() + .pipe( + tap(() => { + triggerRefresh$.next({ force: true }); + }), + switchMap((done) => + // best way on a dashboard to estimate that panels are updated is to rely on search session service state + waitUntilNextSessionCompletes$(data.search.session).pipe(finalize(done)) + ) + ) + .subscribe() + ); + dashboardStateManager.registerChangeListener(() => { setUnsavedChanges(dashboardStateManager.getIsDirty(data.query.timefilter.timefilter)); // we aren't checking dirty state because there are changes the container needs to know about diff --git a/src/plugins/data/README.mdx b/src/plugins/data/README.mdx index 60e74a3fa126c..30006e2b497bd 100644 --- a/src/plugins/data/README.mdx +++ b/src/plugins/data/README.mdx @@ -5,7 +5,7 @@ title: Data services image: https://source.unsplash.com/400x175/?Search summary: The data plugin contains services for searching, querying and filtering. date: 2020-12-02 -tags: ['kibana','dev', 'contributor', 'api docs'] +tags: ['kibana', 'dev', 'contributor', 'api docs'] --- # data @@ -149,7 +149,6 @@ Index patterns provide Rest-like HTTP CRUD+ API with the following endpoints: - Remove a scripted field — `DELETE /api/index_patterns/index_pattern/{id}/scripted_field/{name}` - Update a scripted field — `POST /api/index_patterns/index_pattern/{id}/scripted_field/{name}` - ### Index Patterns API Index Patterns REST API allows you to create, retrieve and delete index patterns. I also @@ -212,11 +211,10 @@ The endpoint returns the created index pattern object. ```json { - "index_pattern": {} + "index_pattern": {} } ``` - #### Fetch an index pattern by ID Retrieve an index pattern by its ID. @@ -229,23 +227,22 @@ Returns an index pattern object. ```json { - "index_pattern": { - "id": "...", - "version": "...", - "title": "...", - "type": "...", - "intervalName": "...", - "timeFieldName": "...", - "sourceFilters": [], - "fields": {}, - "typeMeta": {}, - "fieldFormats": {}, - "fieldAttrs": {} - } + "index_pattern": { + "id": "...", + "version": "...", + "title": "...", + "type": "...", + "intervalName": "...", + "timeFieldName": "...", + "sourceFilters": [], + "fields": {}, + "typeMeta": {}, + "fieldFormats": {}, + "fieldAttrs": {} + } } ``` - #### Delete an index pattern by ID Delete and index pattern by its ID. @@ -256,21 +253,21 @@ DELETE /api/index_patterns/index_pattern/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Returns an '200 OK` response with empty body on success. - #### Partially update an index pattern by ID Update part of an index pattern. Only provided fields will be updated on the index pattern, missing fields will stay as they are persisted. These fields can be update partially: - - `title` - - `timeFieldName` - - `intervalName` - - `fields` (optionally refresh fields) - - `sourceFilters` - - `fieldFormatMap` - - `type` - - `typeMeta` + +- `title` +- `timeFieldName` +- `intervalName` +- `fields` (optionally refresh fields) +- `sourceFilters` +- `fieldFormatMap` +- `type` +- `typeMeta` Update a title of an index pattern. @@ -318,18 +315,14 @@ This endpoint returns the updated index pattern object. ```json { - "index_pattern": { - - } + "index_pattern": {} } ``` - ### Fields API Fields API allows to change field metadata, such as `count`, `customLabel`, and `format`. - #### Update fields Update endpoint allows you to update fields presentation metadata, such as `count`, @@ -383,13 +376,10 @@ This endpoint returns the updated index pattern object. ```json { - "index_pattern": { - - } + "index_pattern": {} } ``` - ### Scripted Fields API Scripted Fields API provides CRUD API for scripted fields of an index pattern. @@ -487,7 +477,7 @@ Returns the field object. ```json { - "field": {} + "field": {} } ``` @@ -529,47 +519,86 @@ POST /api/index_patterns/index_pattern/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/scri } ``` - ## Query The query service is responsible for managing the configuration of a search query (`QueryState`): filters, time range, query string, and settings such as the auto refresh behavior and saved queries. It contains sub-services for each of those configurations: - - `data.query.filterManager` - Manages the `filters` component of a `QueryState`. The global filter state (filters that are persisted between applications) are owned by this service. - - `data.query.timefilter` - Responsible for the time range filter and the auto refresh behavior settings. - - `data.query.queryString` - Responsible for the query string and query language settings. - - `data.query.savedQueries` - Responsible for persisting a `QueryState` into a `SavedObject`, so it can be restored and used by other applications. - Any changes to the `QueryState` are published on the `data.query.state$`, which is useful when wanting to persist global state or run a search upon data changes. +- `data.query.filterManager` - Manages the `filters` component of a `QueryState`. The global filter state (filters that are persisted between applications) are owned by this service. +- `data.query.timefilter` - Responsible for the time range filter and the auto refresh behavior settings. +- `data.query.queryString` - Responsible for the query string and query language settings. +- `data.query.savedQueries` - Responsible for persisting a `QueryState` into a `SavedObject`, so it can be restored and used by other applications. - A simple use case is: +Any changes to the `QueryState` are published on the `data.query.state$`, which is useful when wanting to persist global state or run a search upon data changes. - ```.ts - function searchOnChange(indexPattern: IndexPattern, aggConfigs: AggConfigs) { - data.query.state$.subscribe(() => { +A simple use case is: - // Constuct the query portion of the search request - const query = data.query.getEsQuery(indexPattern); +```.ts +function searchOnChange(indexPattern: IndexPattern, aggConfigs: AggConfigs) { + data.query.state$.subscribe(() => { + + // Constuct the query portion of the search request + const query = data.query.getEsQuery(indexPattern); + + // Construct a request + const request = { + params: { + index: indexPattern.title, + body: { + aggs: aggConfigs.toDsl(), + query, + }, + }, + }; + + // Search with the `data.query` config + const search$ = data.search.search(request); + + ... + }); +} - // Construct a request - const request = { - params: { - index: indexPattern.title, - body: { - aggs: aggConfigs.toDsl(), - query, - }, - }, - }; +``` - // Search with the `data.query` config - const search$ = data.search.search(request); +### Timefilter - ... - }); - } +`data.query.timefilter` is responsible for the time range filter and the auto refresh behavior settings. + +#### Autorefresh - ``` +Timefilter provides an API for setting and getting current auto refresh state: + +```ts +const { pause, value } = data.query.timefilter.timefilter.getRefreshInterval(); + +data.query.timefilter.timefilter.setRefreshInterval({ pause: false, value: 5000 }); // start auto refresh with 5 seconds interval +``` + +Timefilter API also provides an `autoRefreshFetch$` observables that apps should use to get notified +when it is time to refresh data because of auto refresh. +This API expects apps to confirm when they are done with reloading the data. +The confirmation mechanism is needed to prevent excessive queue of fetches. + +``` +import { refetchData } from '../my-app' + +const autoRefreshFetch$ = data.query.timefilter.timefilter.getAutoRefreshFetch$() +autoRefreshFetch$.subscribe((done) => { + try { + await refetchData(); + } finally { + // confirm that data fetching was finished + done(); + } +}) + +function unmount() { + // don't forget to unsubscribe when leaving the app + autoRefreshFetch$.unsubscribe() +} + +``` ## Search diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index c47cd6cd9740d..d2683e248b7bf 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -388,6 +388,8 @@ export { PainlessError, noSearchSessionStorageCapabilityMessage, SEARCH_SESSIONS_MANAGEMENT_ID, + waitUntilNextSessionCompletes$, + WaitUntilNextSessionCompletesOptions, } from './search'; export type { @@ -467,6 +469,7 @@ export { TimeHistoryContract, QueryStateChange, QueryStart, + AutoRefreshDoneFn, } from './query'; export { AggsStart } from './search/aggs'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 415d91f0bcdca..f6a7d032c7017 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -504,6 +504,11 @@ export interface ApplyGlobalFilterActionContext { // @public (undocumented) export type AutocompleteStart = ReturnType; +// Warning: (ae-missing-release-tag) "AutoRefreshDoneFn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AutoRefreshDoneFn = () => void; + // Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "baseFormattersPublic" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2655,6 +2660,18 @@ export const UI_SETTINGS: { readonly AUTOCOMPLETE_USE_TIMERANGE: "autocomplete:useTimeRange"; }; +// Warning: (ae-missing-release-tag) "waitUntilNextSessionCompletes$" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function waitUntilNextSessionCompletes$(sessionService: ISessionService, { waitForIdle }?: WaitUntilNextSessionCompletesOptions): import("rxjs").Observable; + +// Warning: (ae-missing-release-tag) "WaitUntilNextSessionCompletesOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface WaitUntilNextSessionCompletesOptions { + waitForIdle?: number; +} + // Warnings were encountered during analysis: // @@ -2702,21 +2719,21 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:404:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:419:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:428:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:431:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:406:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:419:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:425:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:426:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:429:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:430:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:433: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:56:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/query/timefilter/index.ts b/src/plugins/data/public/query/timefilter/index.ts index 83e897824d86c..3dfd4e0fe514f 100644 --- a/src/plugins/data/public/query/timefilter/index.ts +++ b/src/plugins/data/public/query/timefilter/index.ts @@ -9,7 +9,7 @@ export { TimefilterService, TimefilterSetup } from './timefilter_service'; export * from './types'; -export { Timefilter, TimefilterContract } from './timefilter'; +export { Timefilter, TimefilterContract, AutoRefreshDoneFn } from './timefilter'; export { TimeHistory, TimeHistoryContract } from './time_history'; export { changeTimeFilter, convertRangeFilterToTimeRangeString } from './lib/change_time_filter'; export { extractTimeFilter, extractTimeRange } from './lib/extract_time_filter'; diff --git a/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.test.ts b/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.test.ts new file mode 100644 index 0000000000000..3c8b316c3b878 --- /dev/null +++ b/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright 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 { createAutoRefreshLoop, AutoRefreshDoneFn } from './auto_refresh_loop'; + +jest.useFakeTimers(); + +test('triggers refresh with interval', () => { + const { loop$, start, stop } = createAutoRefreshLoop(); + + const fn = jest.fn((done) => done()); + loop$.subscribe(fn); + + jest.advanceTimersByTime(5000); + expect(fn).not.toBeCalled(); + + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1001); + expect(fn).toHaveBeenCalledTimes(2); + + stop(); + + jest.advanceTimersByTime(5000); + expect(fn).toHaveBeenCalledTimes(2); +}); + +test('waits for done() to be called', () => { + const { loop$, start } = createAutoRefreshLoop(); + + let done!: AutoRefreshDoneFn; + const fn = jest.fn((_done) => { + done = _done; + }); + loop$.subscribe(fn); + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn).toHaveBeenCalledTimes(1); + expect(done).toBeInstanceOf(Function); + + jest.advanceTimersByTime(1001); + expect(fn).toHaveBeenCalledTimes(1); + + done(); + + jest.advanceTimersByTime(500); + expect(fn).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn).toHaveBeenCalledTimes(2); +}); + +test('waits for done() from multiple subscribers to be called', () => { + const { loop$, start } = createAutoRefreshLoop(); + + let done1!: AutoRefreshDoneFn; + const fn1 = jest.fn((_done) => { + done1 = _done; + }); + loop$.subscribe(fn1); + + let done2!: AutoRefreshDoneFn; + const fn2 = jest.fn((_done) => { + done2 = _done; + }); + loop$.subscribe(fn2); + + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + expect(done1).toBeInstanceOf(Function); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(1); + + done2(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(2); +}); + +test('unsubscribe() resets the state', () => { + const { loop$, start } = createAutoRefreshLoop(); + + let done1!: AutoRefreshDoneFn; + const fn1 = jest.fn((_done) => { + done1 = _done; + }); + loop$.subscribe(fn1); + + const fn2 = jest.fn(); + const sub2 = loop$.subscribe(fn2); + + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + expect(done1).toBeInstanceOf(Function); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(1); + + sub2.unsubscribe(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(2); +}); + +test('calling done() twice is ignored', () => { + const { loop$, start } = createAutoRefreshLoop(); + + let done1!: AutoRefreshDoneFn; + const fn1 = jest.fn((_done) => { + done1 = _done; + }); + loop$.subscribe(fn1); + + const fn2 = jest.fn(); + loop$.subscribe(fn2); + + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + expect(done1).toBeInstanceOf(Function); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(1); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(1); +}); + +test('calling older done() is ignored', () => { + const { loop$, start } = createAutoRefreshLoop(); + + let done1!: AutoRefreshDoneFn; + const fn1 = jest.fn((_done) => { + // @ts-ignore + if (done1) return; + done1 = _done; + }); + loop$.subscribe(fn1); + + start(1000); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + expect(done1).toBeInstanceOf(Function); + + jest.advanceTimersByTime(1001); + expect(fn1).toHaveBeenCalledTimes(1); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(2); + + done1(); + + jest.advanceTimersByTime(500); + expect(fn1).toHaveBeenCalledTimes(2); + jest.advanceTimersByTime(501); + expect(fn1).toHaveBeenCalledTimes(2); +}); diff --git a/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.ts b/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.ts new file mode 100644 index 0000000000000..1e213b36e1d8b --- /dev/null +++ b/src/plugins/data/public/query/timefilter/lib/auto_refresh_loop.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { defer, Subject } from 'rxjs'; +import { finalize, map } from 'rxjs/operators'; +import { once } from 'lodash'; + +export type AutoRefreshDoneFn = () => void; + +/** + * Creates a loop for timepicker's auto refresh + * It has a "confirmation" mechanism: + * When auto refresh loop emits, it won't continue automatically, + * until each subscriber calls received `done` function. + * + * @internal + */ +export const createAutoRefreshLoop = () => { + let subscribersCount = 0; + const tick = new Subject(); + + let _timeoutHandle: number; + let _timeout: number = 0; + + function start() { + stop(); + if (_timeout === 0) return; + const timeoutHandle = window.setTimeout(() => { + let pendingDoneCount = subscribersCount; + const done = () => { + if (timeoutHandle !== _timeoutHandle) return; + + pendingDoneCount--; + if (pendingDoneCount === 0) { + start(); + } + }; + tick.next(done); + }, _timeout); + + _timeoutHandle = timeoutHandle; + } + + function stop() { + window.clearTimeout(_timeoutHandle); + _timeoutHandle = -1; + } + + return { + stop: () => { + _timeout = 0; + stop(); + }, + start: (timeout: number) => { + _timeout = timeout; + if (subscribersCount > 0) { + start(); + } + }, + loop$: defer(() => { + subscribersCount++; + start(); // restart the loop on a new subscriber + return tick.pipe(map((doneCb) => once(doneCb))); // each subscriber allowed to call done only once + }).pipe( + finalize(() => { + subscribersCount--; + if (subscribersCount === 0) { + stop(); + } else { + start(); // restart the loop to potentially unblock the interval + } + }) + ), + }; +}; diff --git a/src/plugins/data/public/query/timefilter/timefilter.test.ts b/src/plugins/data/public/query/timefilter/timefilter.test.ts index 8e1e76ed19e6d..92ee6b0c30428 100644 --- a/src/plugins/data/public/query/timefilter/timefilter.test.ts +++ b/src/plugins/data/public/query/timefilter/timefilter.test.ts @@ -10,7 +10,7 @@ jest.useFakeTimers(); import sinon from 'sinon'; import moment from 'moment'; -import { Timefilter } from './timefilter'; +import { AutoRefreshDoneFn, Timefilter } from './timefilter'; import { Subscription } from 'rxjs'; import { TimeRange, RefreshInterval } from '../../../common'; import { createNowProviderMock } from '../../now_provider/mocks'; @@ -121,7 +121,7 @@ describe('setRefreshInterval', () => { beforeEach(() => { update = sinon.spy(); fetch = sinon.spy(); - autoRefreshFetch = sinon.spy(); + autoRefreshFetch = sinon.spy((done) => done()); timefilter.setRefreshInterval({ pause: false, value: 0, @@ -344,3 +344,44 @@ describe('calculateBounds', () => { expect(() => timefilter.calculateBounds(timeRange)).toThrowError(); }); }); + +describe('getAutoRefreshFetch$', () => { + test('next auto refresh loop starts after "done" called', () => { + const autoRefreshFetch = jest.fn(); + let doneCb: AutoRefreshDoneFn | undefined; + timefilter.getAutoRefreshFetch$().subscribe((done) => { + autoRefreshFetch(); + doneCb = done; + }); + timefilter.setRefreshInterval({ pause: false, value: 1000 }); + + expect(autoRefreshFetch).toBeCalledTimes(0); + jest.advanceTimersByTime(5000); + expect(autoRefreshFetch).toBeCalledTimes(1); + + if (doneCb) doneCb(); + + jest.advanceTimersByTime(1005); + expect(autoRefreshFetch).toBeCalledTimes(2); + }); + + test('new getAutoRefreshFetch$ subscription restarts refresh loop', () => { + const autoRefreshFetch = jest.fn(); + const fetch$ = timefilter.getAutoRefreshFetch$(); + const sub1 = fetch$.subscribe((done) => { + autoRefreshFetch(); + // this done will be never called, but loop will be reset by another subscription + }); + timefilter.setRefreshInterval({ pause: false, value: 1000 }); + + expect(autoRefreshFetch).toBeCalledTimes(0); + jest.advanceTimersByTime(5000); + expect(autoRefreshFetch).toBeCalledTimes(1); + + fetch$.subscribe(autoRefreshFetch); + expect(autoRefreshFetch).toBeCalledTimes(1); + sub1.unsubscribe(); + jest.advanceTimersByTime(1005); + expect(autoRefreshFetch).toBeCalledTimes(2); + }); +}); diff --git a/src/plugins/data/public/query/timefilter/timefilter.ts b/src/plugins/data/public/query/timefilter/timefilter.ts index 436b18f70a2f8..9894010601d2b 100644 --- a/src/plugins/data/public/query/timefilter/timefilter.ts +++ b/src/plugins/data/public/query/timefilter/timefilter.ts @@ -22,6 +22,9 @@ import { TimeRange, } from '../../../common'; import { TimeHistoryContract } from './time_history'; +import { createAutoRefreshLoop, AutoRefreshDoneFn } from './lib/auto_refresh_loop'; + +export { AutoRefreshDoneFn }; // TODO: remove! @@ -32,8 +35,6 @@ export class Timefilter { private timeUpdate$ = new Subject(); // Fired when a user changes the the autorefresh settings private refreshIntervalUpdate$ = new Subject(); - // Used when an auto refresh is triggered - private autoRefreshFetch$ = new Subject(); private fetch$ = new Subject(); private _time: TimeRange; @@ -45,11 +46,12 @@ export class Timefilter { private _isTimeRangeSelectorEnabled: boolean = false; private _isAutoRefreshSelectorEnabled: boolean = false; - private _autoRefreshIntervalId: number = 0; - private readonly timeDefaults: TimeRange; private readonly refreshIntervalDefaults: RefreshInterval; + // Used when an auto refresh is triggered + private readonly autoRefreshLoop = createAutoRefreshLoop(); + constructor( config: TimefilterConfig, timeHistory: TimeHistoryContract, @@ -86,9 +88,13 @@ export class Timefilter { return this.refreshIntervalUpdate$.asObservable(); }; - public getAutoRefreshFetch$ = () => { - return this.autoRefreshFetch$.asObservable(); - }; + /** + * Get an observable that emits when it is time to refetch data due to refresh interval + * Each subscription to this observable resets internal interval + * Emitted value is a callback {@link AutoRefreshDoneFn} that must be called to restart refresh interval loop + * Apps should use this callback to start next auto refresh loop when view finished updating + */ + public getAutoRefreshFetch$ = () => this.autoRefreshLoop.loop$; public getFetch$ = () => { return this.fetch$.asObservable(); @@ -166,13 +172,9 @@ export class Timefilter { } } - // Clear the previous auto refresh interval and start a new one (if not paused) - clearInterval(this._autoRefreshIntervalId); - if (!newRefreshInterval.pause) { - this._autoRefreshIntervalId = window.setInterval( - () => this.autoRefreshFetch$.next(), - newRefreshInterval.value - ); + this.autoRefreshLoop.stop(); + if (!newRefreshInterval.pause && newRefreshInterval.value !== 0) { + this.autoRefreshLoop.start(newRefreshInterval.value); } }; diff --git a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts index 0f2b01f618186..c22f62f45a709 100644 --- a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts +++ b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts @@ -20,7 +20,7 @@ const createSetupContractMock = () => { getEnabledUpdated$: jest.fn(), getTimeUpdate$: jest.fn(), getRefreshIntervalUpdate$: jest.fn(), - getAutoRefreshFetch$: jest.fn(() => new Observable()), + getAutoRefreshFetch$: jest.fn(() => new Observable<() => void>()), getFetch$: jest.fn(), getTime: jest.fn(), setTime: jest.fn(), diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index fded4c46992c0..92a5c36202e6f 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -45,6 +45,8 @@ export { ISessionsClient, noSearchSessionStorageCapabilityMessage, SEARCH_SESSIONS_MANAGEMENT_ID, + waitUntilNextSessionCompletes$, + WaitUntilNextSessionCompletesOptions, } from './session'; export { getEsPreference } from './es_search'; diff --git a/src/plugins/data/public/search/session/index.ts b/src/plugins/data/public/search/session/index.ts index 15410400a33e6..ce578378a2fe8 100644 --- a/src/plugins/data/public/search/session/index.ts +++ b/src/plugins/data/public/search/session/index.ts @@ -11,3 +11,7 @@ export { SearchSessionState } from './search_session_state'; export { SessionsClient, ISessionsClient } from './sessions_client'; export { noSearchSessionStorageCapabilityMessage } from './i18n'; export { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; +export { + waitUntilNextSessionCompletes$, + WaitUntilNextSessionCompletesOptions, +} from './session_helpers'; diff --git a/src/plugins/data/public/search/session/session_helpers.test.ts b/src/plugins/data/public/search/session/session_helpers.test.ts new file mode 100644 index 0000000000000..5b64e7b554d18 --- /dev/null +++ b/src/plugins/data/public/search/session/session_helpers.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright 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 { waitUntilNextSessionCompletes$ } from './session_helpers'; +import { ISessionService, SessionService } from './session_service'; +import { BehaviorSubject } from 'rxjs'; +import { SearchSessionState } from './search_session_state'; +import { NowProviderInternalContract } from '../../now_provider'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createNowProviderMock } from '../../now_provider/mocks'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; +import { getSessionsClientMock } from './mocks'; + +let sessionService: ISessionService; +let state$: BehaviorSubject; +let nowProvider: jest.Mocked; +let currentAppId$: BehaviorSubject; + +beforeEach(() => { + const initializerContext = coreMock.createPluginInitializerContext(); + const startService = coreMock.createSetup().getStartServices; + nowProvider = createNowProviderMock(); + currentAppId$ = new BehaviorSubject('app'); + sessionService = new SessionService( + initializerContext, + () => + startService().then(([coreStart, ...rest]) => [ + { + ...coreStart, + application: { + ...coreStart.application, + currentAppId$, + capabilities: { + ...coreStart.application.capabilities, + management: { + kibana: { + [SEARCH_SESSIONS_MANAGEMENT_ID]: true, + }, + }, + }, + }, + }, + ...rest, + ]), + getSessionsClientMock(), + nowProvider, + { freezeState: false } // needed to use mocks inside state container + ); + state$ = new BehaviorSubject(SearchSessionState.None); + sessionService.state$.subscribe(state$); +}); + +describe('waitUntilNextSessionCompletes$', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + test('emits when next session starts', () => { + sessionService.start(); + let untrackSearch = sessionService.trackSearch({ abort: () => {} }); + untrackSearch(); + + const next = jest.fn(); + const complete = jest.fn(); + waitUntilNextSessionCompletes$(sessionService).subscribe({ next, complete }); + expect(next).not.toBeCalled(); + + sessionService.start(); + expect(next).not.toBeCalled(); + + untrackSearch = sessionService.trackSearch({ abort: () => {} }); + untrackSearch(); + + expect(next).not.toBeCalled(); + jest.advanceTimersByTime(500); + expect(next).not.toBeCalled(); + jest.advanceTimersByTime(1000); + expect(next).toBeCalledTimes(1); + expect(complete).toBeCalled(); + }); +}); diff --git a/src/plugins/data/public/search/session/session_helpers.ts b/src/plugins/data/public/search/session/session_helpers.ts new file mode 100644 index 0000000000000..1f0a2da7e93f4 --- /dev/null +++ b/src/plugins/data/public/search/session/session_helpers.ts @@ -0,0 +1,48 @@ +/* + * Copyright 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 { debounceTime, first, skipUntil } from 'rxjs/operators'; +import { ISessionService } from './session_service'; +import { SearchSessionState } from './search_session_state'; + +/** + * Options for {@link waitUntilNextSessionCompletes$} + */ +export interface WaitUntilNextSessionCompletesOptions { + /** + * For how long to wait between session state transitions before considering that session completed + */ + waitForIdle?: number; +} + +/** + * Creates an observable that emits when next search session completes. + * This utility is helpful to use in the application to delay some tasks until next session completes. + * + * @param sessionService - {@link ISessionService} + * @param opts - {@link WaitUntilNextSessionCompletesOptions} + */ +export function waitUntilNextSessionCompletes$( + sessionService: ISessionService, + { waitForIdle = 1000 }: WaitUntilNextSessionCompletesOptions = { waitForIdle: 1000 } +) { + return sessionService.state$.pipe( + // wait until new session starts + skipUntil(sessionService.state$.pipe(first((state) => state === SearchSessionState.None))), + // wait until new session starts loading + skipUntil(sessionService.state$.pipe(first((state) => state === SearchSessionState.Loading))), + // debounce to ignore quick switches from loading <-> completed. + // that could happen between sequential search requests inside a single session + debounceTime(waitForIdle), + // then wait until it finishes + first( + (state) => + state === SearchSessionState.Completed || state === SearchSessionState.BackgroundCompleted + ) + ); +} diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 2c80fc111c740..3be047859d3b0 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { merge, Subject, Subscription } from 'rxjs'; -import { debounceTime } from 'rxjs/operators'; +import { debounceTime, tap, filter } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { createSearchSessionRestorationDataProvider, getState, splitState } from './discover_state'; import { RequestAdapter } from '../../../../inspector/public'; @@ -393,12 +393,11 @@ function discoverController($route, $scope) { $scope.state.index = $scope.indexPattern.id; $scope.state.sort = getSortArray($scope.state.sort, $scope.indexPattern); - $scope.opts.fetch = $scope.fetch = function () { + $scope.opts.fetch = $scope.fetch = async function () { $scope.fetchCounter++; $scope.fetchError = undefined; if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { $scope.resultState = 'none'; - return; } // Abort any in-progress requests before fetching again @@ -494,11 +493,19 @@ function discoverController($route, $scope) { showUnmappedFields, }; + // handler emitted by `timefilter.getAutoRefreshFetch$()` + // to notify when data completed loading and to start a new autorefresh loop + let autoRefreshDoneCb; const fetch$ = merge( refetch$, filterManager.getFetches$(), timefilter.getFetch$(), - timefilter.getAutoRefreshFetch$(), + timefilter.getAutoRefreshFetch$().pipe( + tap((done) => { + autoRefreshDoneCb = done; + }), + filter(() => $scope.fetchStatus !== fetchStatuses.LOADING) + ), data.query.queryString.getUpdates$(), searchSessionManager.newSearchSessionIdFromURL$ ).pipe(debounceTime(100)); @@ -508,7 +515,16 @@ function discoverController($route, $scope) { $scope, fetch$, { - next: $scope.fetch, + next: async () => { + try { + await $scope.fetch(); + } finally { + // if there is a saved `autoRefreshDoneCb`, notify auto refresh service that + // the last fetch is completed so it starts the next auto refresh loop if needed + autoRefreshDoneCb?.(); + autoRefreshDoneCb = undefined; + } + }, }, (error) => addFatalError(core.fatalErrors, error) ) diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 65925b5a2e4c2..4165b8906a20e 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -118,12 +118,15 @@ export class ExpressionLoader { return this.execution ? (this.execution.inspect() as Adapters) : undefined; } - update(expression?: string | ExpressionAstExpression, params?: IExpressionLoaderParams): void { + async update( + expression?: string | ExpressionAstExpression, + params?: IExpressionLoaderParams + ): Promise { this.setParams(params); this.loadingSubject.next(true); if (expression) { - this.loadData(expression, this.params); + await this.loadData(expression, this.params); } else if (this.data) { this.render(this.data); } diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 429dabeeef042..efb166c8975bb 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -367,8 +367,8 @@ export class VisualizeEmbeddable } } - public reload = () => { - this.handleVisUpdate(); + public reload = async () => { + await this.handleVisUpdate(); }; private async updateHandler() { @@ -395,13 +395,13 @@ export class VisualizeEmbeddable }); if (this.handler && !abortController.signal.aborted) { - this.handler.update(this.expression, expressionParams); + await this.handler.update(this.expression, expressionParams); } } private handleVisUpdate = async () => { this.handleChanges(); - this.updateHandler(); + await this.updateHandler(); }; private uiStateChangeHandler = () => { diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index 256e634ac6c40..f6ef1caf9c9e0 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -183,8 +183,12 @@ const TopNav = ({ useEffect(() => { const autoRefreshFetchSub = services.data.query.timefilter.timefilter .getAutoRefreshFetch$() - .subscribe(() => { - visInstance.embeddableHandler.reload(); + .subscribe(async (done) => { + try { + await visInstance.embeddableHandler.reload(); + } finally { + done(); + } }); return () => { autoRefreshFetchSub.unsubscribe(); diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 20bf349f6b13a..b7dbf1bbe4d87 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -155,11 +155,7 @@ function createMockTimefilter() { getBounds: jest.fn(() => timeFilter), getRefreshInterval: () => {}, getRefreshIntervalDefaults: () => {}, - getAutoRefreshFetch$: () => ({ - subscribe: ({ next }: { next: () => void }) => { - return next; - }, - }), + getAutoRefreshFetch$: () => new Observable(), }; } diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index dbc10c751a649..39163101fc7bd 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -14,6 +14,7 @@ import { Toast } from 'kibana/public'; import { VisualizeFieldContext } from 'src/plugins/ui_actions/public'; import { Datatable } from 'src/plugins/expressions/public'; import { EuiBreadcrumb } from '@elastic/eui'; +import { finalize, switchMap, tap } from 'rxjs/operators'; import { downloadMultipleAs } from '../../../../../src/plugins/share/public'; import { createKbnUrlStateStorage, @@ -37,6 +38,7 @@ import { Query, SavedQuery, syncQueryStateWithUrl, + waitUntilNextSessionCompletes$, } from '../../../../../src/plugins/data/public'; import { LENS_EMBEDDABLE_TYPE, getFullPath, APP_ID } from '../../common'; import { LensAppProps, LensAppServices, LensAppState } from './types'; @@ -193,14 +195,19 @@ export function App({ const autoRefreshSubscription = data.query.timefilter.timefilter .getAutoRefreshFetch$() - .subscribe({ - next: () => { + .pipe( + tap(() => { setState((s) => ({ ...s, searchSessionId: data.search.session.start(), })); - }, - }); + }), + switchMap((done) => + // best way in lens to estimate that all panels are updated is to rely on search session service state + waitUntilNextSessionCompletes$(data.search.session).pipe(finalize(done)) + ) + ) + .subscribe(); const kbnUrlStateStorage = createKbnUrlStateStorage({ history, From 81adccd389b8c064b7a26e61f6071664b81eb8d4 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 8 Apr 2021 10:20:43 -0400 Subject: [PATCH 13/22] [Security Solution][Endpoint] Endpoint Event Filtering List, Test Data Generator and Loader (#96263) (#96456) * Added new const to List plugin for new Endpont Event Filter list * Data Generator for event filters ++ script to load event filters (WIP) * refactor `generate_data` to use `BaseDataGenerator` class Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/lists/common/constants.ts | 9 ++ .../create_endoint_event_filters_list.ts | 79 +++++++++++++ .../data_generators/base_data_generator.ts | 94 +++++++++++++++ .../data_generators/event_filter_generator.ts | 30 +++++ .../common/endpoint/generate_data.ts | 56 ++------- .../scripts/endpoint/event_filters/index.ts | 111 ++++++++++++++++++ .../scripts/endpoint/load_event_filters.js | 11 ++ 7 files changed, 341 insertions(+), 49 deletions(-) create mode 100644 x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts create mode 100644 x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts create mode 100644 x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts create mode 100755 x-pack/plugins/security_solution/scripts/endpoint/load_event_filters.js diff --git a/x-pack/plugins/lists/common/constants.ts b/x-pack/plugins/lists/common/constants.ts index 92d8b6f5f7571..4f897c83cb41d 100644 --- a/x-pack/plugins/lists/common/constants.ts +++ b/x-pack/plugins/lists/common/constants.ts @@ -60,3 +60,12 @@ export const ENDPOINT_TRUSTED_APPS_LIST_NAME = 'Endpoint Security Trusted Apps L /** Description of trusted apps agnostic list */ export const ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION = 'Endpoint Security Trusted Apps List'; + +/** ID of event filters agnostic list */ +export const ENDPOINT_EVENT_FILTERS_LIST_ID = 'endpoint_event_filters'; + +/** Name of event filters agnostic list */ +export const ENDPOINT_EVENT_FILTERS_LIST_NAME = 'Endpoint Security Event Filters List'; + +/** Description of event filters agnostic list */ +export const ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION = 'Endpoint Security Event Filters List'; diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts new file mode 100644 index 0000000000000..95e9df03400af --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import uuid from 'uuid'; + +import { + ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, + ENDPOINT_EVENT_FILTERS_LIST_ID, + ENDPOINT_EVENT_FILTERS_LIST_NAME, +} from '../../../common/constants'; +import { ExceptionListSchema, ExceptionListSoSchema, Version } from '../../../common/schemas'; + +import { getSavedObjectType, transformSavedObjectToExceptionList } from './utils'; + +interface CreateEndpointEventFiltersListOptions { + savedObjectsClient: SavedObjectsClientContract; + user: string; + tieBreaker?: string; + version: Version; +} + +/** + * Creates the Endpoint Trusted Apps agnostic list if it does not yet exist + * + * @param savedObjectsClient + * @param user + * @param tieBreaker + * @param version + */ +export const createEndpointEventFiltersList = async ({ + savedObjectsClient, + user, + tieBreaker, + version, +}: CreateEndpointEventFiltersListOptions): Promise => { + const savedObjectType = getSavedObjectType({ namespaceType: 'agnostic' }); + const dateNow = new Date().toISOString(); + try { + const savedObject = await savedObjectsClient.create( + savedObjectType, + { + comments: undefined, + created_at: dateNow, + created_by: user, + description: ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, + entries: undefined, + immutable: false, + item_id: undefined, + list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, + list_type: 'list', + meta: undefined, + name: ENDPOINT_EVENT_FILTERS_LIST_NAME, + os_types: [], + tags: [], + tie_breaker_id: tieBreaker ?? uuid.v4(), + type: 'endpoint', + updated_by: user, + version, + }, + { + // We intentionally hard coding the id so that there can only be one Event Filters list within the space + id: ENDPOINT_EVENT_FILTERS_LIST_ID, + } + ); + + return transformSavedObjectToExceptionList({ savedObject }); + } catch (err) { + if (savedObjectsClient.errors.isConflictError(err)) { + return null; + } else { + throw err; + } + } +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts new file mode 100644 index 0000000000000..c0888a6c2a4bd --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 seedrandom from 'seedrandom'; +import uuid from 'uuid'; + +const OS_FAMILY = ['windows', 'macos', 'linux']; + +/** + * A generic base class to assist in creating domain specific data generators. It includes + * several general purpose random data generators for use within the class and exposes one + * public method named `generate()` which should be implemented by sub-classes. + */ +export class BaseDataGenerator { + protected random: seedrandom.prng; + + constructor(seed: string | seedrandom.prng = Math.random().toString()) { + if (typeof seed === 'string') { + this.random = seedrandom(seed); + } else { + this.random = seed; + } + } + + /** + * Generate a new record + */ + public generate(): GeneratedDoc { + throw new Error('method not implemented!'); + } + + /** generate random OS family value */ + protected randomOSFamily(): string { + return this.randomChoice(OS_FAMILY); + } + + /** generate a UUID (v4) */ + protected randomUUID(): string { + return uuid.v4(); + } + + /** Generate a random number up to the max provided */ + protected randomN(max: number): number { + return Math.floor(this.random() * max); + } + + protected *randomNGenerator(max: number, count: number) { + let iCount = count; + while (iCount > 0) { + yield this.randomN(max); + iCount = iCount - 1; + } + } + + /** + * Create an array of a given size and fill it with data provided by a generator + * + * @param lengthLimit + * @param generator + * @protected + */ + protected randomArray(lengthLimit: number, generator: () => T): T[] { + const rand = this.randomN(lengthLimit) + 1; + return [...Array(rand).keys()].map(generator); + } + + protected randomMac(): string { + return [...this.randomNGenerator(255, 6)].map((x) => x.toString(16)).join('-'); + } + + protected randomIP(): string { + return [10, ...this.randomNGenerator(255, 3)].map((x) => x.toString()).join('.'); + } + + protected randomVersion(): string { + return [6, ...this.randomNGenerator(10, 2)].map((x) => x.toString()).join('.'); + } + + protected randomChoice(choices: T[]): T { + return choices[this.randomN(choices.length)]; + } + + protected randomString(length: number): string { + return [...this.randomNGenerator(36, length)].map((x) => x.toString(36)).join(''); + } + + protected randomHostname(): string { + return `Host-${this.randomString(10)}`; + } +} diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts new file mode 100644 index 0000000000000..6bdbb9cde2034 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BaseDataGenerator } from './base_data_generator'; +import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '../../../../lists/common/constants'; +import { CreateExceptionListItemSchema } from '../../../../lists/common'; +import { getCreateExceptionListItemSchemaMock } from '../../../../lists/common/schemas/request/create_exception_list_item_schema.mock'; + +export class EventFilterGenerator extends BaseDataGenerator { + generate(): CreateExceptionListItemSchema { + const overrides: Partial = { + name: `generator event ${this.randomString(5)}`, + list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, + item_id: `generator_endpoint_event_filter_${this.randomUUID()}`, + os_types: [this.randomOSFamily()] as CreateExceptionListItemSchema['os_types'], + tags: ['policy:all'], + namespace_type: 'agnostic', + meta: undefined, + }; + + return Object.assign>( + getCreateExceptionListItemSchemaMock(), + overrides + ); + } +} diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 8aec9768dd50d..36d0b0cbf3b21 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -35,6 +35,7 @@ import { EsAssetReference, KibanaAssetReference } from '../../../fleet/common/ty import { agentPolicyStatuses } from '../../../fleet/common/constants'; import { firstNonNullValue } from './models/ecs_safety_helpers'; import { EventOptions } from './types/generator'; +import { BaseDataGenerator } from './data_generators/base_data_generator'; export type Event = AlertEvent | SafeEndpointEvent; /** @@ -386,9 +387,8 @@ const alertsDefaultDataStream = { namespace: 'default', }; -export class EndpointDocGenerator { +export class EndpointDocGenerator extends BaseDataGenerator { commonInfo: HostInfo; - random: seedrandom.prng; sequence: number = 0; /** * The EndpointDocGenerator parameters @@ -396,12 +396,7 @@ export class EndpointDocGenerator { * @param seed either a string to seed the random number generator or a random number generator function */ constructor(seed: string | seedrandom.prng = Math.random().toString()) { - if (typeof seed === 'string') { - this.random = seedrandom(seed); - } else { - this.random = seed; - } - + super(seed); this.commonInfo = this.createHostData(); } @@ -1568,47 +1563,6 @@ export class EndpointDocGenerator { }; } - private randomN(n: number): number { - return Math.floor(this.random() * n); - } - - private *randomNGenerator(max: number, count: number) { - let iCount = count; - while (iCount > 0) { - yield this.randomN(max); - iCount = iCount - 1; - } - } - - private randomArray(lengthLimit: number, generator: () => T): T[] { - const rand = this.randomN(lengthLimit) + 1; - return [...Array(rand).keys()].map(generator); - } - - private randomMac(): string { - return [...this.randomNGenerator(255, 6)].map((x) => x.toString(16)).join('-'); - } - - public randomIP(): string { - return [10, ...this.randomNGenerator(255, 3)].map((x) => x.toString()).join('.'); - } - - private randomVersion(): string { - return [6, ...this.randomNGenerator(10, 2)].map((x) => x.toString()).join('.'); - } - - private randomChoice(choices: T[]): T { - return choices[this.randomN(choices.length)]; - } - - private randomString(length: number): string { - return [...this.randomNGenerator(36, length)].map((x) => x.toString(36)).join(''); - } - - private randomHostname(): string { - return `Host-${this.randomString(10)}`; - } - private seededUUIDv4(): string { return uuid.v4({ random: [...this.randomNGenerator(255, 16)] }); } @@ -1646,6 +1600,10 @@ export class EndpointDocGenerator { private randomProcessName(): string { return this.randomChoice(fakeProcessNames); } + + public randomIP(): string { + return super.randomIP(); + } } const fakeProcessNames = [ diff --git a/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts new file mode 100644 index 0000000000000..93af1f406300c --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.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 { run, RunFn, createFailError } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; +import { AxiosError } from 'axios'; +import bluebird from 'bluebird'; +import { EventFilterGenerator } from '../../../common/endpoint/data_generators/event_filter_generator'; +import { + ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, + ENDPOINT_EVENT_FILTERS_LIST_ID, + ENDPOINT_EVENT_FILTERS_LIST_NAME, + EXCEPTION_LIST_ITEM_URL, + EXCEPTION_LIST_URL, +} from '../../../../lists/common/constants'; +import { CreateExceptionListSchema } from '../../../../lists/common'; + +export const cli = () => { + run( + async (options) => { + try { + await createEventFilters(options); + options.log.success(`${options.flags.count} endpoint event filters created`); + } catch (e) { + options.log.error(e); + throw createFailError(e.message); + } + }, + { + description: 'Load Endpoint Event Filters', + flags: { + string: ['kibana'], + default: { + count: 10, + kibana: 'http://elastic:changeme@localhost:5601', + }, + help: ` + --count Number of event filters to create. Default: 10 + --kibana The URL to kibana including credentials. Default: http://elastic:changeme@localhost:5601 + `, + }, + } + ); +}; + +class EventFilterDataLoaderError extends Error { + constructor(message: string, public readonly meta: unknown) { + super(message); + } +} + +const handleThrowAxiosHttpError = (err: AxiosError): never => { + let message = err.message; + + if (err.response) { + message = `[${err.response.status}] ${err.response.data.message ?? err.message} [ ${String( + err.response.config.method + ).toUpperCase()} ${err.response.config.url} ]`; + } + throw new EventFilterDataLoaderError(message, err.toJSON()); +}; + +const createEventFilters: RunFn = async ({ flags, log }) => { + const eventGenerator = new EventFilterGenerator(); + const kbn = new KbnClient({ log, url: flags.kibana as string }); + + await ensureCreateEndpointEventFiltersList(kbn); + + await bluebird.map( + Array.from({ length: (flags.count as unknown) as number }), + () => + kbn + .request({ + method: 'POST', + path: EXCEPTION_LIST_ITEM_URL, + body: eventGenerator.generate(), + }) + .catch((e) => handleThrowAxiosHttpError(e)), + { concurrency: 10 } + ); +}; + +const ensureCreateEndpointEventFiltersList = async (kbn: KbnClient) => { + const newListDefinition: CreateExceptionListSchema = { + description: ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, + list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, + meta: undefined, + name: ENDPOINT_EVENT_FILTERS_LIST_NAME, + os_types: [], + tags: [], + type: 'endpoint', + namespace_type: 'agnostic', + }; + + await kbn + .request({ + method: 'POST', + path: EXCEPTION_LIST_URL, + body: newListDefinition, + }) + .catch((e) => { + // Ignore if list was already created + if (e.response.status !== 409) { + handleThrowAxiosHttpError(e); + } + }); +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/load_event_filters.js b/x-pack/plugins/security_solution/scripts/endpoint/load_event_filters.js new file mode 100755 index 0000000000000..ca0f4ff9365c5 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/load_event_filters.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +require('../../../../../src/setup_node_env'); +require('./event_filters').cli(); From 07f226c032263a17b9dded9c8d9621041e857a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 8 Apr 2021 16:44:44 +0200 Subject: [PATCH 14/22] [Remote clusters] Cloud deployment form when adding new cluster (#94450) (#96558) * Implemented in-form Cloud deployment url input * Fixed i18n files and added mode switch back for non-Cloud * Added cloud docs link to the documentation service, fixed snapshot tests * Fixed docs build * Added jest test for the new cloud url input * Added unit test for cloud validation * Fixed eslint error * Fixed ts errors * Added ts-ignore * Fixed ts errors * Refactored connection mode component and component tests * Fixed import * Fixed copy * Fixed copy * Reverted docs changes * Reverted docs changes * Replaced the screenshot with a popover and refactored integration tests * Added todo for cloud deployments link * Changed cloud URL help text copy * Added cloud base url and deleted unnecessary base path * Fixed es error * Fixed es error * Changed wording * Reverted docs changes * Updated the help popover * Deleted unneeded fragment component * Deleted unneeded fragment component * Updated tests descriptions to be more detailed Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../add/remote_clusters_add.helpers.js | 43 - .../add/remote_clusters_add.helpers.tsx | 47 + .../add/remote_clusters_add.test.js | 230 -- .../add/remote_clusters_add.test.ts | 260 +++ ...al_characters.js => special_characters.ts} | 0 .../edit/remote_clusters_edit.helpers.js | 34 - .../edit/remote_clusters_edit.helpers.tsx | 58 + .../edit/remote_clusters_edit.test.js | 80 - .../edit/remote_clusters_edit.test.tsx | 141 ++ .../{http_requests.js => http_requests.ts} | 19 +- .../helpers/{index.js => index.ts} | 1 + .../helpers/remote_clusters_actions.ts | 199 ++ ...up_environment.js => setup_environment.ts} | 2 + .../public/application/app_context.tsx | 10 +- .../public/application/index.d.ts | 3 +- .../remote_cluster_form.test.js.snap | 2017 ----------------- .../components/cloud_url_help.tsx | 61 + .../components/connection_mode.tsx | 99 + .../remote_cluster_form/components/index.ts | 8 + .../components/proxy_connection.tsx | 162 ++ .../components/sniff_connection.tsx | 158 ++ .../{index.js => index.ts} | 0 .../remote_cluster_form.js | 962 -------- .../remote_cluster_form.test.js | 53 - .../remote_cluster_form.tsx | 629 +++++ .../remote_cluster_form/request_flyout.tsx | 4 +- .../remote_cluster_form/validators/index.ts | 7 + .../validators/validate_cloud_url.test.ts | 128 ++ .../validators/validate_cloud_url.tsx | 80 + .../validators/validate_cluster.tsx | 39 + .../validators/validate_seed.ts | 43 - .../validators/validate_seed.tsx | 40 + .../public/application/sections/index.d.ts | 11 + .../remote_cluster_add/remote_cluster_add.js | 2 +- .../remote_cluster_edit.js | 7 +- .../public/application/services/api.ts | 2 +- .../application/services/documentation.ts | 10 +- .../public/application/services/index.ts | 8 +- .../public/application/services/routing.ts | 2 +- .../public/application/store/index.d.ts | 11 + .../public/assets/cloud_screenshot.png | Bin 0 -> 197089 bytes .../plugins/remote_clusters/public/plugin.ts | 5 +- x-pack/plugins/remote_clusters/tsconfig.json | 2 + .../translations/translations/ja-JP.json | 5 - .../translations/translations/zh-CN.json | 5 - 45 files changed, 2184 insertions(+), 3503 deletions(-) delete mode 100644 x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.js create mode 100644 x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.tsx delete mode 100644 x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.js create mode 100644 x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts rename x-pack/plugins/remote_clusters/__jest__/client_integration/add/{special_characters.js => special_characters.ts} (100%) delete mode 100644 x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.js create mode 100644 x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.tsx delete mode 100644 x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.js create mode 100644 x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx rename x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/{http_requests.js => http_requests.ts} (63%) rename x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/{index.js => index.ts} (80%) create mode 100644 x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts rename x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/{setup_environment.js => setup_environment.ts} (95%) delete mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/index.ts create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/sniff_connection.tsx rename x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/{index.js => index.ts} (100%) delete mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js delete mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.test.js create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.tsx create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx delete mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.ts create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.tsx create mode 100644 x-pack/plugins/remote_clusters/public/application/sections/index.d.ts create mode 100644 x-pack/plugins/remote_clusters/public/application/store/index.d.ts create mode 100644 x-pack/plugins/remote_clusters/public/assets/cloud_screenshot.png diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.js deleted file mode 100644 index 38672b4d59a20..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act } from 'react-dom/test-utils'; - -import { registerTestBed } from '@kbn/test/jest'; - -import { RemoteClusterAdd } from '../../../public/application/sections/remote_cluster_add'; -import { createRemoteClustersStore } from '../../../public/application/store'; -import { registerRouter } from '../../../public/application/services/routing'; - -const testBedConfig = { - store: createRemoteClustersStore, - memoryRouter: { - onRouter: (router) => registerRouter(router), - }, -}; - -const initTestBed = registerTestBed(RemoteClusterAdd, testBedConfig); - -export const setup = (props) => { - const testBed = initTestBed(props); - - // User actions - const clickSaveForm = async () => { - await act(async () => { - testBed.find('remoteClusterFormSaveButton').simulate('click'); - }); - - testBed.component.update(); - }; - - return { - ...testBed, - actions: { - clickSaveForm, - }, - }; -}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.tsx b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.tsx new file mode 100644 index 0000000000000..a47e6c023a161 --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.helpers.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { registerTestBed } from '@kbn/test/jest'; + +import { RemoteClusterAdd } from '../../../public/application/sections'; +import { createRemoteClustersStore } from '../../../public/application/store'; +import { AppRouter, registerRouter } from '../../../public/application/services'; +import { createRemoteClustersActions } from '../helpers'; +import { AppContextProvider } from '../../../public/application/app_context'; + +const ComponentWithContext = ({ isCloudEnabled }: { isCloudEnabled: boolean }) => { + return ( + + + + ); +}; + +const testBedConfig = ({ isCloudEnabled }: { isCloudEnabled: boolean }) => { + return { + store: createRemoteClustersStore, + memoryRouter: { + onRouter: (router: AppRouter) => registerRouter(router), + }, + defaultProps: { isCloudEnabled }, + }; +}; + +const initTestBed = (isCloudEnabled: boolean) => + registerTestBed(ComponentWithContext, testBedConfig({ isCloudEnabled }))(); + +export const setup = async (isCloudEnabled = false) => { + const testBed = await initTestBed(isCloudEnabled); + + return { + ...testBed, + actions: { + ...createRemoteClustersActions(testBed), + }, + }; +}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.js deleted file mode 100644 index 40abde35835f0..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.js +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act } from 'react-dom/test-utils'; - -import { setupEnvironment } from '../helpers'; -import { NON_ALPHA_NUMERIC_CHARS, ACCENTED_CHARS } from './special_characters'; -import { setup } from './remote_clusters_add.helpers'; - -describe('Create Remote cluster', () => { - describe('on component mount', () => { - let find; - let exists; - let actions; - let form; - let server; - let component; - - beforeAll(() => { - ({ server } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); - }); - - beforeEach(async () => { - await act(async () => { - ({ form, exists, find, actions, component } = setup()); - }); - component.update(); - }); - - test('should have the title of the page set correctly', () => { - expect(exists('remoteClusterPageTitle')).toBe(true); - expect(find('remoteClusterPageTitle').text()).toEqual('Add remote cluster'); - }); - - test('should have a link to the documentation', () => { - expect(exists('remoteClusterDocsButton')).toBe(true); - }); - - test('should have a toggle to Skip unavailable remote cluster', () => { - expect(exists('remoteClusterFormSkipUnavailableFormToggle')).toBe(true); - - // By default it should be set to "false" - expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe( - false - ); - - act(() => { - form.toggleEuiSwitch('remoteClusterFormSkipUnavailableFormToggle'); - }); - - component.update(); - - expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe(true); - }); - - test('should have a toggle to enable "proxy" mode for a remote cluster', () => { - expect(exists('remoteClusterFormConnectionModeToggle')).toBe(true); - - // By default it should be set to "false" - expect(find('remoteClusterFormConnectionModeToggle').props()['aria-checked']).toBe(false); - - act(() => { - form.toggleEuiSwitch('remoteClusterFormConnectionModeToggle'); - }); - - component.update(); - - expect(find('remoteClusterFormConnectionModeToggle').props()['aria-checked']).toBe(true); - }); - - test('should display errors and disable the save button when clicking "save" without filling the form', async () => { - expect(exists('remoteClusterFormGlobalError')).toBe(false); - expect(find('remoteClusterFormSaveButton').props().disabled).toBe(false); - - await actions.clickSaveForm(); - - expect(exists('remoteClusterFormGlobalError')).toBe(true); - expect(form.getErrorsMessages()).toEqual([ - 'Name is required.', - 'At least one seed node is required.', - ]); - expect(find('remoteClusterFormSaveButton').props().disabled).toBe(true); - }); - }); - - describe('form validation', () => { - describe('remote cluster name', () => { - let component; - let actions; - let form; - - beforeEach(async () => { - await act(async () => { - ({ component, form, actions } = setup()); - }); - - component.update(); - }); - - test('should not allow spaces', async () => { - form.setInputValue('remoteClusterFormNameInput', 'with space'); - - await actions.clickSaveForm(); - - expect(form.getErrorsMessages()).toContain('Spaces are not allowed in the name.'); - }); - - test('should only allow alpha-numeric characters, "-" (dash) and "_" (underscore)', async () => { - const expectInvalidChar = (char) => { - if (char === '-' || char === '_') { - return; - } - - try { - form.setInputValue('remoteClusterFormNameInput', `with${char}`); - - expect(form.getErrorsMessages()).toContain( - `Remove the character ${char} from the name.` - ); - } catch { - throw Error(`Char "${char}" expected invalid but was allowed`); - } - }; - - await actions.clickSaveForm(); // display form errors - - [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS].forEach(expectInvalidChar); - }); - }); - - describe('seeds', () => { - let actions; - let form; - let component; - - beforeEach(async () => { - await act(async () => { - ({ form, actions, component } = setup()); - }); - - component.update(); - - form.setInputValue('remoteClusterFormNameInput', 'remote_cluster_test'); - }); - - test('should only allow alpha-numeric characters and "-" (dash) in the node "host" part', async () => { - await actions.clickSaveForm(); // display form errors - - const notInArray = (array) => (value) => array.indexOf(value) < 0; - - const expectInvalidChar = (char) => { - form.setComboBoxValue('remoteClusterFormSeedsInput', `192.16${char}:3000`); - expect(form.getErrorsMessages()).toContain( - `Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.` - ); - }; - - [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] - .filter(notInArray(['-', '_', ':'])) - .forEach(expectInvalidChar); - }); - - test('should require a numeric "port" to be set', async () => { - await actions.clickSaveForm(); - - form.setComboBoxValue('remoteClusterFormSeedsInput', '192.168.1.1'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - - form.setComboBoxValue('remoteClusterFormSeedsInput', '192.168.1.1:abc'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - }); - }); - - describe('proxy address', () => { - let actions; - let form; - let component; - - beforeEach(async () => { - await act(async () => { - ({ form, actions, component } = setup()); - }); - - component.update(); - - act(() => { - // Enable "proxy" mode - form.toggleEuiSwitch('remoteClusterFormConnectionModeToggle'); - }); - - component.update(); - }); - - test('should only allow alpha-numeric characters and "-" (dash) in the proxy address "host" part', async () => { - await actions.clickSaveForm(); // display form errors - - const notInArray = (array) => (value) => array.indexOf(value) < 0; - - const expectInvalidChar = (char) => { - form.setInputValue('remoteClusterFormProxyAddressInput', `192.16${char}:3000`); - expect(form.getErrorsMessages()).toContain( - 'Address must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.' - ); - }; - - [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] - .filter(notInArray(['-', '_', ':'])) - .forEach(expectInvalidChar); - }); - - test('should require a numeric "port" to be set', async () => { - await actions.clickSaveForm(); - - form.setInputValue('remoteClusterFormProxyAddressInput', '192.168.1.1'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - - form.setInputValue('remoteClusterFormProxyAddressInput', '192.168.1.1:abc'); - expect(form.getErrorsMessages()).toContain('A port is required.'); - }); - }); - }); -}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts new file mode 100644 index 0000000000000..0727bc0c9ba2d --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts @@ -0,0 +1,260 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { SinonFakeServer } from 'sinon'; +import { TestBed } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; + +import { setupEnvironment, RemoteClustersActions } from '../helpers'; +import { setup } from './remote_clusters_add.helpers'; +import { NON_ALPHA_NUMERIC_CHARS, ACCENTED_CHARS } from './special_characters'; + +const notInArray = (array: string[]) => (value: string) => array.indexOf(value) < 0; + +let component: TestBed['component']; +let actions: RemoteClustersActions; +let server: SinonFakeServer; + +describe('Create Remote cluster', () => { + beforeAll(() => { + ({ server } = setupEnvironment()); + }); + + afterAll(() => { + server.restore(); + }); + + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup()); + }); + component.update(); + }); + + describe('on component mount', () => { + test('should have the title of the page set correctly', () => { + expect(actions.pageTitle.exists()).toBe(true); + expect(actions.pageTitle.text()).toEqual('Add remote cluster'); + }); + + test('should have a link to the documentation', () => { + expect(actions.docsButtonExists()).toBe(true); + }); + + test('should have a toggle to Skip unavailable remote cluster', () => { + expect(actions.skipUnavailableSwitch.exists()).toBe(true); + + // By default it should be set to "false" + expect(actions.skipUnavailableSwitch.isChecked()).toBe(false); + + actions.skipUnavailableSwitch.toggle(); + + expect(actions.skipUnavailableSwitch.isChecked()).toBe(true); + }); + + describe('on prem', () => { + test('should have a toggle to enable "proxy" mode for a remote cluster', () => { + expect(actions.connectionModeSwitch.exists()).toBe(true); + + // By default it should be set to "false" + expect(actions.connectionModeSwitch.isChecked()).toBe(false); + + actions.connectionModeSwitch.toggle(); + + expect(actions.connectionModeSwitch.isChecked()).toBe(true); + }); + + test('server name has optional label', () => { + actions.connectionModeSwitch.toggle(); + expect(actions.serverNameInput.getLabel()).toBe('Server name (optional)'); + }); + + test('should display errors and disable the save button when clicking "save" without filling the form', async () => { + expect(actions.globalErrorExists()).toBe(false); + expect(actions.saveButton.isDisabled()).toBe(false); + + await actions.saveButton.click(); + + expect(actions.globalErrorExists()).toBe(true); + expect(actions.getErrorMessages()).toEqual([ + 'Name is required.', + // seeds input is switched on by default on prem and is required + 'At least one seed node is required.', + ]); + expect(actions.saveButton.isDisabled()).toBe(true); + }); + + test('renders no switch for cloud url input and proxy address + server name input modes', () => { + expect(actions.cloudUrlSwitch.exists()).toBe(false); + }); + }); + describe('on cloud', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup(true)); + }); + + component.update(); + }); + + test('renders a switch between cloud url input and proxy address + server name input for proxy connection', () => { + expect(actions.cloudUrlSwitch.exists()).toBe(true); + }); + + test('renders no switch between sniff and proxy modes', () => { + expect(actions.connectionModeSwitch.exists()).toBe(false); + }); + test('defaults to cloud url input for proxy connection', () => { + expect(actions.cloudUrlSwitch.isChecked()).toBe(false); + }); + test('server name has no optional label', () => { + actions.cloudUrlSwitch.toggle(); + expect(actions.serverNameInput.getLabel()).toBe('Server name'); + }); + }); + }); + describe('form validation', () => { + describe('remote cluster name', () => { + test('should not allow spaces', async () => { + actions.nameInput.setValue('with space'); + + await actions.saveButton.click(); + + expect(actions.getErrorMessages()).toContain('Spaces are not allowed in the name.'); + }); + + test('should only allow alpha-numeric characters, "-" (dash) and "_" (underscore)', async () => { + const expectInvalidChar = (char: string) => { + if (char === '-' || char === '_') { + return; + } + + try { + actions.nameInput.setValue(`with${char}`); + + expect(actions.getErrorMessages()).toContain( + `Remove the character ${char} from the name.` + ); + } catch { + throw Error(`Char "${char}" expected invalid but was allowed`); + } + }; + + await actions.saveButton.click(); // display form errors + + [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS].forEach(expectInvalidChar); + }); + }); + + describe('proxy address', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup()); + }); + + component.update(); + + actions.connectionModeSwitch.toggle(); + }); + + test('should only allow alpha-numeric characters and "-" (dash) in the proxy address "host" part', async () => { + await actions.saveButton.click(); // display form errors + + const expectInvalidChar = (char: string) => { + actions.proxyAddressInput.setValue(`192.16${char}:3000`); + expect(actions.getErrorMessages()).toContain( + 'Address must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.' + ); + }; + + [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] + .filter(notInArray(['-', '_', ':'])) + .forEach(expectInvalidChar); + }); + + test('should require a numeric "port" to be set', async () => { + await actions.saveButton.click(); + + actions.proxyAddressInput.setValue('192.168.1.1'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + + actions.proxyAddressInput.setValue('192.168.1.1:abc'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + }); + }); + + describe('on prem', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup()); + }); + + component.update(); + + actions.nameInput.setValue('remote_cluster_test'); + }); + + describe('seeds', () => { + test('should only allow alpha-numeric characters and "-" (dash) in the node "host" part', async () => { + await actions.saveButton.click(); // display form errors + + const expectInvalidChar = (char: string) => { + actions.seedsInput.setValue(`192.16${char}:3000`); + expect(actions.getErrorMessages()).toContain( + `Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.` + ); + }; + + [...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS] + .filter(notInArray(['-', '_', ':'])) + .forEach(expectInvalidChar); + }); + + test('should require a numeric "port" to be set', async () => { + await actions.saveButton.click(); + + actions.seedsInput.setValue('192.168.1.1'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + + actions.seedsInput.setValue('192.168.1.1:abc'); + expect(actions.getErrorMessages()).toContain('A port is required.'); + }); + }); + + test('server name is optional (proxy connection)', () => { + actions.connectionModeSwitch.toggle(); + actions.saveButton.click(); + expect(actions.getErrorMessages()).toEqual(['A proxy address is required.']); + }); + }); + + describe('on cloud', () => { + beforeEach(async () => { + await act(async () => { + ({ actions, component } = await setup(true)); + }); + + component.update(); + }); + + test('cloud url is required since cloud url input is enabled by default', () => { + actions.saveButton.click(); + expect(actions.getErrorMessages()).toContain('A url is required.'); + }); + + test('proxy address and server name are required when cloud url input is disabled', () => { + actions.cloudUrlSwitch.toggle(); + actions.saveButton.click(); + expect(actions.getErrorMessages()).toEqual([ + 'Name is required.', + 'A proxy address is required.', + 'A server name is required.', + ]); + }); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.ts similarity index 100% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/add/special_characters.ts diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.js deleted file mode 100644 index 094fb5056e983..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { registerTestBed } from '@kbn/test/jest'; - -import { RemoteClusterEdit } from '../../../public/application/sections/remote_cluster_edit'; -import { createRemoteClustersStore } from '../../../public/application/store'; -import { registerRouter } from '../../../public/application/services/routing'; - -export const REMOTE_CLUSTER_EDIT_NAME = 'new-york'; - -export const REMOTE_CLUSTER_EDIT = { - name: REMOTE_CLUSTER_EDIT_NAME, - seeds: ['localhost:9400'], - skipUnavailable: true, -}; - -const testBedConfig = { - store: createRemoteClustersStore, - memoryRouter: { - onRouter: (router) => registerRouter(router), - // The remote cluster name to edit is read from the router ":id" param - // so we first set it in our initial entries - initialEntries: [`/${REMOTE_CLUSTER_EDIT_NAME}`], - // and then we declarae the :id param on the component route path - componentRoutePath: '/:name', - }, -}; - -export const setup = registerTestBed(RemoteClusterEdit, testBedConfig); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.tsx b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.tsx new file mode 100644 index 0000000000000..2259396bf33f2 --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.helpers.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { registerTestBed, TestBedConfig } from '@kbn/test/jest'; + +import React from 'react'; +import { RemoteClusterEdit } from '../../../public/application/sections'; +import { createRemoteClustersStore } from '../../../public/application/store'; +import { AppRouter, registerRouter } from '../../../public/application/services'; +import { createRemoteClustersActions } from '../helpers'; +import { AppContextProvider } from '../../../public/application/app_context'; + +export const REMOTE_CLUSTER_EDIT_NAME = 'new-york'; + +export const REMOTE_CLUSTER_EDIT = { + name: REMOTE_CLUSTER_EDIT_NAME, + seeds: ['localhost:9400'], + skipUnavailable: true, +}; + +const ComponentWithContext = (props: { isCloudEnabled: boolean }) => { + const { isCloudEnabled, ...rest } = props; + return ( + + + + ); +}; + +const testBedConfig: TestBedConfig = { + store: createRemoteClustersStore, + memoryRouter: { + onRouter: (router: AppRouter) => registerRouter(router), + // The remote cluster name to edit is read from the router ":id" param + // so we first set it in our initial entries + initialEntries: [`/${REMOTE_CLUSTER_EDIT_NAME}`], + // and then we declare the :id param on the component route path + componentRoutePath: '/:name', + }, +}; + +const initTestBed = (isCloudEnabled: boolean) => + registerTestBed(ComponentWithContext, testBedConfig)({ isCloudEnabled }); + +export const setup = async (isCloudEnabled = false) => { + const testBed = await initTestBed(isCloudEnabled); + + return { + ...testBed, + actions: { + ...createRemoteClustersActions(testBed), + }, + }; +}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.js deleted file mode 100644 index 19dd468cb76c5..0000000000000 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.js +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act } from 'react-dom/test-utils'; - -import { RemoteClusterForm } from '../../../public/application/sections/components/remote_cluster_form'; -import { setupEnvironment } from '../helpers'; -import { setup as setupRemoteClustersAdd } from '../add/remote_clusters_add.helpers'; -import { - setup, - REMOTE_CLUSTER_EDIT, - REMOTE_CLUSTER_EDIT_NAME, -} from './remote_clusters_edit.helpers'; - -describe('Edit Remote cluster', () => { - let component; - let find; - let exists; - - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); - - httpRequestsMockHelpers.setLoadRemoteClustersResponse([REMOTE_CLUSTER_EDIT]); - - beforeEach(async () => { - await act(async () => { - ({ component, find, exists } = setup()); - }); - component.update(); - }); - - test('should have the title of the page set correctly', () => { - expect(exists('remoteClusterPageTitle')).toBe(true); - expect(find('remoteClusterPageTitle').text()).toEqual('Edit remote cluster'); - }); - - test('should have a link to the documentation', () => { - expect(exists('remoteClusterDocsButton')).toBe(true); - }); - - /** - * As the "edit" remote cluster component uses the same form underneath that - * the "create" remote cluster, we won't test it again but simply make sure that - * the form component is indeed shared between the 2 app sections. - */ - test('should use the same Form component as the "" component', async () => { - let addRemoteClusterTestBed; - - await act(async () => { - addRemoteClusterTestBed = setupRemoteClustersAdd(); - }); - - addRemoteClusterTestBed.component.update(); - - const formEdit = component.find(RemoteClusterForm); - const formAdd = addRemoteClusterTestBed.component.find(RemoteClusterForm); - - expect(formEdit.length).toBe(1); - expect(formAdd.length).toBe(1); - }); - - test('should populate the form fields with the values from the remote cluster loaded', () => { - expect(find('remoteClusterFormNameInput').props().value).toBe(REMOTE_CLUSTER_EDIT_NAME); - expect(find('remoteClusterFormSeedsInput').text()).toBe(REMOTE_CLUSTER_EDIT.seeds.join('')); - expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe( - REMOTE_CLUSTER_EDIT.skipUnavailable - ); - }); - - test('should disable the form name input', () => { - expect(find('remoteClusterFormNameInput').props().disabled).toBe(true); - }); -}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx new file mode 100644 index 0000000000000..2913de94bc2dd --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/edit/remote_clusters_edit.test.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { TestBed } from '@kbn/test/jest'; + +import { RemoteClusterForm } from '../../../public/application/sections/components/remote_cluster_form'; +import { RemoteClustersActions, setupEnvironment } from '../helpers'; +import { setup as setupRemoteClustersAdd } from '../add/remote_clusters_add.helpers'; +import { + setup, + REMOTE_CLUSTER_EDIT, + REMOTE_CLUSTER_EDIT_NAME, +} from './remote_clusters_edit.helpers'; +import { Cluster } from '../../../common/lib'; + +let component: TestBed['component']; +let actions: RemoteClustersActions; +const { server, httpRequestsMockHelpers } = setupEnvironment(); + +describe('Edit Remote cluster', () => { + afterAll(() => { + server.restore(); + }); + + httpRequestsMockHelpers.setLoadRemoteClustersResponse([REMOTE_CLUSTER_EDIT]); + + beforeEach(async () => { + await act(async () => { + ({ component, actions } = await setup()); + }); + component.update(); + }); + + test('should have the title of the page set correctly', () => { + expect(actions.pageTitle.exists()).toBe(true); + expect(actions.pageTitle.text()).toEqual('Edit remote cluster'); + }); + + test('should have a link to the documentation', () => { + expect(actions.docsButtonExists()).toBe(true); + }); + + /** + * As the "edit" remote cluster component uses the same form underneath that + * the "create" remote cluster, we won't test it again but simply make sure that + * the form component is indeed shared between the 2 app sections. + */ + test('should use the same Form component as the "" component', async () => { + let addRemoteClusterTestBed: TestBed; + + await act(async () => { + addRemoteClusterTestBed = await setupRemoteClustersAdd(); + }); + + addRemoteClusterTestBed!.component.update(); + + const formEdit = component.find(RemoteClusterForm); + const formAdd = addRemoteClusterTestBed!.component.find(RemoteClusterForm); + + expect(formEdit.length).toBe(1); + expect(formAdd.length).toBe(1); + }); + + test('should populate the form fields with the values from the remote cluster loaded', () => { + expect(actions.nameInput.getValue()).toBe(REMOTE_CLUSTER_EDIT_NAME); + // seeds input for sniff connection is not shown on Cloud + expect(actions.seedsInput.getValue()).toBe(REMOTE_CLUSTER_EDIT.seeds.join('')); + expect(actions.skipUnavailableSwitch.isChecked()).toBe(REMOTE_CLUSTER_EDIT.skipUnavailable); + }); + + test('should disable the form name input', () => { + expect(actions.nameInput.isDisabled()).toBe(true); + }); + + describe('on cloud', () => { + const cloudUrl = 'cloud-url'; + const defaultCloudPort = '9400'; + test('existing cluster that defaults to cloud url (default port)', async () => { + const cluster: Cluster = { + name: REMOTE_CLUSTER_EDIT_NAME, + mode: 'proxy', + proxyAddress: `${cloudUrl}:${defaultCloudPort}`, + serverName: cloudUrl, + }; + httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]); + + await act(async () => { + ({ component, actions } = await setup(true)); + }); + component.update(); + + expect(actions.cloudUrlInput.exists()).toBe(true); + expect(actions.cloudUrlInput.getValue()).toBe(cloudUrl); + }); + + test('existing cluster that defaults to manual input (non-default port)', async () => { + const cluster: Cluster = { + name: REMOTE_CLUSTER_EDIT_NAME, + mode: 'proxy', + proxyAddress: `${cloudUrl}:9500`, + serverName: cloudUrl, + }; + httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]); + + await act(async () => { + ({ component, actions } = await setup(true)); + }); + component.update(); + + expect(actions.cloudUrlInput.exists()).toBe(false); + + expect(actions.proxyAddressInput.exists()).toBe(true); + expect(actions.serverNameInput.exists()).toBe(true); + }); + + test('existing cluster that defaults to manual input (proxy address is different from server name)', async () => { + const cluster: Cluster = { + name: REMOTE_CLUSTER_EDIT_NAME, + mode: 'proxy', + proxyAddress: `${cloudUrl}:${defaultCloudPort}`, + serverName: 'another-value', + }; + httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]); + + await act(async () => { + ({ component, actions } = await setup(true)); + }); + component.update(); + + expect(actions.cloudUrlInput.exists()).toBe(false); + + expect(actions.proxyAddressInput.exists()).toBe(true); + expect(actions.serverNameInput.exists()).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.ts similarity index 63% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.ts index 304ec51986aba..3ebe3ab5738d6 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/http_requests.ts @@ -5,25 +5,24 @@ * 2.0. */ -import sinon from 'sinon'; +import sinon, { SinonFakeServer } from 'sinon'; +import { Cluster } from '../../../common/lib'; // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server) => { - const mockResponse = (response) => [ +const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { + const mockResponse = (response: Cluster[] | { itemsDeleted: string[]; errors: string[] }) => [ 200, { 'Content-Type': 'application/json' }, JSON.stringify(response), ]; - const setLoadRemoteClustersResponse = (response) => { - server.respondWith('GET', '/api/remote_clusters', [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); + const setLoadRemoteClustersResponse = (response: Cluster[] = []) => { + server.respondWith('GET', '/api/remote_clusters', mockResponse(response)); }; - const setDeleteRemoteClusterResponse = (response) => { + const setDeleteRemoteClusterResponse = ( + response: { itemsDeleted: string[]; errors: string[] } = { itemsDeleted: [], errors: [] } + ) => { server.respondWith('DELETE', /api\/remote_clusters/, mockResponse(response)); }; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.ts similarity index 80% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.ts index 63084b21e3902..cf859ff6913f5 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/index.ts @@ -7,3 +7,4 @@ export { nextTick, getRandomString, findTestSubject } from '@kbn/test/jest'; export { setupEnvironment } from './setup_environment'; +export { createRemoteClustersActions, RemoteClustersActions } from './remote_clusters_actions'; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts new file mode 100644 index 0000000000000..ba0c424793838 --- /dev/null +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_actions.ts @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { TestBed } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; +export interface RemoteClustersActions { + docsButtonExists: () => boolean; + pageTitle: { + exists: () => boolean; + text: () => string; + }; + nameInput: { + setValue: (name: string) => void; + getValue: () => string; + isDisabled: () => boolean; + }; + skipUnavailableSwitch: { + exists: () => boolean; + toggle: () => void; + isChecked: () => boolean; + }; + connectionModeSwitch: { + exists: () => boolean; + toggle: () => void; + isChecked: () => boolean; + }; + cloudUrlSwitch: { + toggle: () => void; + exists: () => boolean; + isChecked: () => boolean; + }; + cloudUrlInput: { + exists: () => boolean; + getValue: () => string; + }; + seedsInput: { + setValue: (seed: string) => void; + getValue: () => string; + }; + proxyAddressInput: { + setValue: (proxyAddress: string) => void; + exists: () => boolean; + }; + serverNameInput: { + getLabel: () => string; + exists: () => boolean; + }; + saveButton: { + click: () => void; + isDisabled: () => boolean; + }; + getErrorMessages: () => string[]; + globalErrorExists: () => boolean; +} +export const createRemoteClustersActions = (testBed: TestBed): RemoteClustersActions => { + const { form, exists, find, component } = testBed; + + const docsButtonExists = () => exists('remoteClusterDocsButton'); + const createPageTitleActions = () => { + const pageTitleSelector = 'remoteClusterPageTitle'; + return { + pageTitle: { + exists: () => exists(pageTitleSelector), + text: () => find(pageTitleSelector).text(), + }, + }; + }; + const createNameInputActions = () => { + const nameInputSelector = 'remoteClusterFormNameInput'; + return { + nameInput: { + setValue: (name: string) => form.setInputValue(nameInputSelector, name), + getValue: () => find(nameInputSelector).props().value, + isDisabled: () => find(nameInputSelector).props().disabled, + }, + }; + }; + + const createSkipUnavailableActions = () => { + const skipUnavailableToggleSelector = 'remoteClusterFormSkipUnavailableFormToggle'; + return { + skipUnavailableSwitch: { + exists: () => exists(skipUnavailableToggleSelector), + toggle: () => { + act(() => { + form.toggleEuiSwitch(skipUnavailableToggleSelector); + }); + component.update(); + }, + isChecked: () => find(skipUnavailableToggleSelector).props()['aria-checked'], + }, + }; + }; + + const createConnectionModeActions = () => { + const connectionModeToggleSelector = 'remoteClusterFormConnectionModeToggle'; + return { + connectionModeSwitch: { + exists: () => exists(connectionModeToggleSelector), + toggle: () => { + act(() => { + form.toggleEuiSwitch(connectionModeToggleSelector); + }); + component.update(); + }, + isChecked: () => find(connectionModeToggleSelector).props()['aria-checked'], + }, + }; + }; + + const createCloudUrlSwitchActions = () => { + const cloudUrlSelector = 'remoteClusterFormCloudUrlToggle'; + return { + cloudUrlSwitch: { + exists: () => exists(cloudUrlSelector), + toggle: () => { + act(() => { + form.toggleEuiSwitch(cloudUrlSelector); + }); + component.update(); + }, + isChecked: () => find(cloudUrlSelector).props()['aria-checked'], + }, + }; + }; + + const createSeedsInputActions = () => { + const seedsInputSelector = 'remoteClusterFormSeedsInput'; + return { + seedsInput: { + setValue: (seed: string) => form.setComboBoxValue(seedsInputSelector, seed), + getValue: () => find(seedsInputSelector).text(), + }, + }; + }; + + const createProxyAddressActions = () => { + const proxyAddressSelector = 'remoteClusterFormProxyAddressInput'; + return { + proxyAddressInput: { + setValue: (proxyAddress: string) => form.setInputValue(proxyAddressSelector, proxyAddress), + exists: () => exists(proxyAddressSelector), + }, + }; + }; + + const createSaveButtonActions = () => { + const click = () => { + act(() => { + find('remoteClusterFormSaveButton').simulate('click'); + }); + + component.update(); + }; + const isDisabled = () => find('remoteClusterFormSaveButton').props().disabled; + return { saveButton: { click, isDisabled } }; + }; + + const createServerNameActions = () => { + const serverNameSelector = 'remoteClusterFormServerNameFormRow'; + return { + serverNameInput: { + getLabel: () => find('remoteClusterFormServerNameFormRow').find('label').text(), + exists: () => exists(serverNameSelector), + }, + }; + }; + + const globalErrorExists = () => exists('remoteClusterFormGlobalError'); + + const createCloudUrlInputActions = () => { + const cloudUrlInputSelector = 'remoteClusterFormCloudUrlInput'; + return { + cloudUrlInput: { + exists: () => exists(cloudUrlInputSelector), + getValue: () => find(cloudUrlInputSelector).props().value, + }, + }; + }; + return { + docsButtonExists, + ...createPageTitleActions(), + ...createNameInputActions(), + ...createSkipUnavailableActions(), + ...createConnectionModeActions(), + ...createCloudUrlSwitchActions(), + ...createSeedsInputActions(), + ...createCloudUrlInputActions(), + ...createProxyAddressActions(), + ...createServerNameActions(), + ...createSaveButtonActions(), + getErrorMessages: form.getErrorsMessages, + globalErrorExists, + }; +}; diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.ts similarity index 95% rename from x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.js rename to x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.ts index 97ad344a63cc4..084552c5e6abe 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/setup_environment.ts @@ -36,6 +36,8 @@ export const setupEnvironment = () => { notificationServiceMock.createSetupContract().toasts, fatalErrorsServiceMock.createSetupContract() ); + // This expects HttpSetup but we're giving it AxiosInstance. + // @ts-ignore initHttp(mockHttpClient); const { server, httpRequestsMockHelpers } = initHttpRequests(); diff --git a/x-pack/plugins/remote_clusters/public/application/app_context.tsx b/x-pack/plugins/remote_clusters/public/application/app_context.tsx index 7931001c6faee..528ec322f49e1 100644 --- a/x-pack/plugins/remote_clusters/public/application/app_context.tsx +++ b/x-pack/plugins/remote_clusters/public/application/app_context.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React, { createContext } from 'react'; +import React, { createContext, useContext } from 'react'; export interface Context { isCloudEnabled: boolean; + cloudBaseUrl: string; } export const AppContext = createContext({} as any); @@ -22,3 +23,10 @@ export const AppContextProvider = ({ }) => { return {children}; }; + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) throw new Error('Cannot use outside of app context'); + + return ctx; +}; diff --git a/x-pack/plugins/remote_clusters/public/application/index.d.ts b/x-pack/plugins/remote_clusters/public/application/index.d.ts index 167297cedf556..45f981b5f2bc5 100644 --- a/x-pack/plugins/remote_clusters/public/application/index.d.ts +++ b/x-pack/plugins/remote_clusters/public/application/index.d.ts @@ -12,7 +12,8 @@ export declare const renderApp: ( elem: HTMLElement | null, I18nContext: I18nStart['Context'], appDependencies: { - isCloudEnabled?: boolean; + isCloudEnabled: boolean; + cloudBaseUrl: string; }, history: ScopedHistory ) => ReturnType; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap deleted file mode 100644 index 5f09193be90c2..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap +++ /dev/null @@ -1,2017 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RemoteClusterForm proxy mode renders correct connection settings when user enables proxy mode 1`] = ` - - -
- - } - fullWidth={true} - title={ - -

- -

-
- } - > -
- -
- -
- - -

- - Name - -

-
-
- -
- -
- - A unique name for the cluster. - -
-
-
-
-
-
- -
- - } - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - helpText={ - - } - isInvalid={false} - label={ - - } - labelType="label" - > -
-
- - - -
-
- - -
-
- - - - -
-
-
-
- -
- - Name can only contain letters, numbers, underscores, and dashes. - -
-
-
-
-
-
-
-
-
-
-
- - - - - } - onChange={[Function]} - /> - - - } - fullWidth={true} - title={ - -

- -

-
- } - > -
- -
- -
- - -

- - Connection mode - -

-
-
- -
- -
- - Use seed nodes by default, or switch to proxy mode. - - -
-
- - } - onBlur={[Function]} - onChange={[Function]} - onFocus={[Function]} - > -
- - - - Use proxy mode - - -
-
-
-
-
-
-
-
-
-
-
- -
- - } - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - helpText={ - - } - isInvalid={false} - label={ - - } - labelType="label" - > -
-
- - - -
-
- - -
-
- - - - -
-
-
-
- -
- - The address to use for remote connections. - -
-
-
-
-
- - - , - } - } - /> - } - isInvalid={false} - label={ - - } - labelType="label" - > -
-
- - - -
-
- - -
-
- - - - -
-
-
-
- -
- - - , - } - } - > - A string sent in the server_name field of the TLS Server Name Indication extension if TLS is enabled. - - - - -
-
-
-
-
- - } - label={ - - } - labelType="label" - > -
-
- - - -
-
- - -
-
- - - - -
-
-
-
- -
- - The number of socket connections to open per remote cluster. - -
-
-
-
-
-
-
-
-
-
-
- -

- - - , - "optionName": - - , - } - } - /> -

- - } - fullWidth={true} - title={ - -

- -

-
- } - > -
- -
- -
- - -

- - Make remote cluster optional - -

-
-
- -
- -
-

- - - , - "optionName": - - , - } - } - > - A request fails if any of the queried remote clusters are unavailable. To send requests to other remote clusters if this cluster is unavailable, enable - - - Skip if unavailable - - - . - - - - -

-
-
-
-
-
-
- -
- -
-
- -
- - - Skip if unavailable - -
-
-
-
-
-
-
-
-
-
-
-
-
- -
- - -
- -
- -
- -
- - - - - -
-
-
-
-
-
- -
- - - -
-
-
-
- -`; - -exports[`RemoteClusterForm renders untouched state 1`] = ` -Array [ -
-
-
-
-

- Name -

-
-
- A unique name for the cluster. -
-
-
-
-
-
- -
-
-
-
- -
-
-
- Name can only contain letters, numbers, underscores, and dashes. -
-
-
-
-
-
-
-
-
-

- Connection mode -

-
-
- Use seed nodes by default, or switch to proxy mode. -
-
-
- - - Use proxy mode - -
-
-
-
-
-
-
-
-
- -
-
- -
-
-
- -
-
-
-
- -
-
-
- The number of gateway nodes to connect to for this cluster. -
-
-
-
-
-
-
-
-
-

- Make remote cluster optional -

-
-
-

- A request fails if any of the queried remote clusters are unavailable. To send requests to other remote clusters if this cluster is unavailable, enable - - Skip if unavailable - - . - -

-
-
-
-
-
-
-
- - - Skip if unavailable - -
-
-
-
-
-
-
, -
, -
-
-
-
- -
-
-
-
- -
-
, -] -`; - -exports[`RemoteClusterForm validation renders invalid state and a global form error when the user tries to submit an invalid form 1`] = ` -Array [ -
-
- -
-
-
-
- -
-
-
- Name is required. -
-
- Name can only contain letters, numbers, underscores, and dashes. -
-
-
, -
-
- -
-
- -
, -
-
-
- - - Skip if unavailable - -
-
-
, -
, -] -`; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx new file mode 100644 index 0000000000000..1d4862ff094ce --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/cloud_url_help.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiLink, EuiPopover, EuiPopoverTitle, EuiText } from '@elastic/eui'; +import { useAppContext } from '../../../../app_context'; + +export const CloudUrlHelp: FunctionComponent = () => { + const [isOpen, setIsOpen] = useState(false); + const { cloudBaseUrl } = useAppContext(); + return ( + + { + setIsOpen(!isOpen); + }} + > + + + + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="upCenter" + > + + + + + + + + ), + elasticsearch: Elasticsearch, + }} + /> + + + ); +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx new file mode 100644 index 0000000000000..d06b4f111ec92 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/connection_mode.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiDescribedFormGroup, EuiTitle, EuiFormRow, EuiSwitch, EuiSpacer } from '@elastic/eui'; + +import { SNIFF_MODE, PROXY_MODE } from '../../../../../../common/constants'; +import { useAppContext } from '../../../../app_context'; + +import { ClusterErrors } from '../validators'; +import { SniffConnection } from './sniff_connection'; +import { ProxyConnection } from './proxy_connection'; +import { FormFields } from '../remote_cluster_form'; + +export interface Props { + fields: FormFields; + onFieldsChange: (fields: Partial) => void; + fieldsErrors: ClusterErrors; + areErrorsVisible: boolean; +} + +export const ConnectionMode: FunctionComponent = (props) => { + const { fields, onFieldsChange } = props; + const { mode, cloudUrlEnabled } = fields; + const { isCloudEnabled } = useAppContext(); + + return ( + +

+ +

+ + } + description={ + <> + {isCloudEnabled ? ( + <> + + + + } + checked={!cloudUrlEnabled} + data-test-subj="remoteClusterFormCloudUrlToggle" + onChange={(e) => onFieldsChange({ cloudUrlEnabled: !e.target.checked })} + /> + + + + ) : ( + <> + + + + } + checked={mode === PROXY_MODE} + data-test-subj="remoteClusterFormConnectionModeToggle" + onChange={(e) => + onFieldsChange({ mode: e.target.checked ? PROXY_MODE : SNIFF_MODE }) + } + /> + + + )} + + } + fullWidth + > + {mode === SNIFF_MODE ? : } +
+ ); +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/index.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/index.ts new file mode 100644 index 0000000000000..864385ad0b1a3 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/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 { ConnectionMode } from './connection_mode'; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx new file mode 100644 index 0000000000000..04e8533a0d2af --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/proxy_connection.tsx @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiFieldNumber, EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui'; +import { useAppContext } from '../../../../app_context'; +import { proxySettingsUrl } from '../../../../services/documentation'; +import { Props } from './connection_mode'; +import { CloudUrlHelp } from './cloud_url_help'; + +export const ProxyConnection: FunctionComponent = (props) => { + const { fields, fieldsErrors, areErrorsVisible, onFieldsChange } = props; + const { isCloudEnabled } = useAppContext(); + const { proxyAddress, serverName, proxySocketConnections, cloudUrl, cloudUrlEnabled } = fields; + const { + proxyAddress: proxyAddressError, + serverName: serverNameError, + cloudUrl: cloudUrlError, + } = fieldsErrors; + + return ( + <> + {cloudUrlEnabled ? ( + <> + + } + labelAppend={} + isInvalid={Boolean(areErrorsVisible && cloudUrlError)} + error={cloudUrlError} + fullWidth + helpText={ + + } + > + onFieldsChange({ cloudUrl: e.target.value })} + isInvalid={Boolean(areErrorsVisible && cloudUrlError)} + data-test-subj="remoteClusterFormCloudUrlInput" + fullWidth + /> + + + ) : ( + <> + + } + helpText={ + + } + isInvalid={Boolean(areErrorsVisible && proxyAddressError)} + error={proxyAddressError} + fullWidth + > + onFieldsChange({ proxyAddress: e.target.value })} + isInvalid={Boolean(areErrorsVisible && proxyAddressError)} + data-test-subj="remoteClusterFormProxyAddressInput" + fullWidth + /> + + + + ) : ( + + ) + } + helpText={ + + + + ), + }} + /> + } + fullWidth + > + onFieldsChange({ serverName: e.target.value })} + isInvalid={Boolean(areErrorsVisible && serverNameError)} + fullWidth + /> + + + )} + + } + helpText={ + + } + fullWidth + > + onFieldsChange({ proxySocketConnections: Number(e.target.value) })} + fullWidth + /> + + + ); +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/sniff_connection.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/sniff_connection.tsx new file mode 100644 index 0000000000000..063aeb3490aef --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/components/sniff_connection.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFieldNumber, + EuiFormRow, + EuiLink, +} from '@elastic/eui'; + +import { transportPortUrl } from '../../../../services/documentation'; +import { validateSeed } from '../validators'; +import { Props } from './connection_mode'; + +export const SniffConnection: FunctionComponent = ({ + fields, + fieldsErrors, + areErrorsVisible, + onFieldsChange, +}) => { + const [localSeedErrors, setLocalSeedErrors] = useState([]); + const { seeds = [], nodeConnections } = fields; + const { seeds: seedsError } = fieldsErrors; + // Show errors if there is a general form error or local errors. + const areFormErrorsVisible = Boolean(areErrorsVisible && seedsError); + const showErrors = areFormErrorsVisible || localSeedErrors.length !== 0; + const errors = + areFormErrorsVisible && seedsError ? localSeedErrors.concat(seedsError) : localSeedErrors; + const formattedSeeds: EuiComboBoxOptionOption[] = seeds.map((seed: string) => ({ label: seed })); + + const onCreateSeed = (newSeed?: string) => { + // If the user just hit enter without typing anything, treat it as a no-op. + if (!newSeed) { + return; + } + + const validationErrors = validateSeed(newSeed); + + if (validationErrors.length !== 0) { + setLocalSeedErrors(validationErrors); + // Return false to explicitly reject the user's input. + return false; + } + + const newSeeds = seeds.slice(0); + newSeeds.push(newSeed.toLowerCase()); + onFieldsChange({ seeds: newSeeds }); + }; + + const onSeedsInputChange = (seedInput?: string) => { + if (!seedInput) { + // If empty seedInput ("") don't do anything. This happens + // right after a seed is created. + return; + } + + // Allow typing to clear the errors, but not to add new ones. + const validationErrors = + !seedInput || validateSeed(seedInput).length === 0 ? [] : localSeedErrors; + + // EuiComboBox internally checks for duplicates and prevents calling onCreateOption if the + // input is a duplicate. So we need to surface this error here instead. + const isDuplicate = seeds.includes(seedInput); + + if (isDuplicate) { + validationErrors.push( + + ); + } + + setLocalSeedErrors(validationErrors); + }; + return ( + <> + + } + helpText={ + + + + ), + }} + /> + } + isInvalid={showErrors} + error={errors} + fullWidth + > + + onFieldsChange({ seeds: options.map(({ label }) => label) }) + } + onSearchChange={onSeedsInputChange} + isInvalid={showErrors} + fullWidth + data-test-subj="remoteClusterFormSeedsInput" + /> + + + + } + helpText={ + + } + fullWidth + > + onFieldsChange({ nodeConnections: Number(e.target.value) })} + fullWidth + /> + + + ); +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/index.js b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/index.ts similarity index 100% rename from x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/index.js rename to x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/index.ts diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js deleted file mode 100644 index 325215d08af5f..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js +++ /dev/null @@ -1,962 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { merge } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButton, - EuiButtonEmpty, - EuiCallOut, - EuiComboBox, - EuiDescribedFormGroup, - EuiFieldNumber, - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiFormRow, - EuiLink, - EuiLoadingKibana, - EuiLoadingSpinner, - EuiOverlayMask, - EuiSpacer, - EuiSwitch, - EuiText, - EuiTitle, - EuiDelayRender, - EuiScreenReaderOnly, - htmlIdGenerator, -} from '@elastic/eui'; - -import { - skippingDisconnectedClustersUrl, - transportPortUrl, - proxySettingsUrl, -} from '../../../services/documentation'; - -import { RequestFlyout } from './request_flyout'; - -import { - validateName, - validateSeeds, - validateProxy, - validateSeed, - validateServerName, -} from './validators'; - -import { SNIFF_MODE, PROXY_MODE } from '../../../../../common/constants'; - -import { AppContext } from '../../../app_context'; - -const defaultFields = { - name: '', - seeds: [], - skipUnavailable: false, - nodeConnections: 3, - proxyAddress: '', - proxySocketConnections: 18, - serverName: '', -}; - -const ERROR_TITLE_ID = 'removeClustersErrorTitle'; -const ERROR_LIST_ID = 'removeClustersErrorList'; - -export class RemoteClusterForm extends Component { - static propTypes = { - save: PropTypes.func.isRequired, - cancel: PropTypes.func, - isSaving: PropTypes.bool, - saveError: PropTypes.object, - fields: PropTypes.object, - disabledFields: PropTypes.object, - }; - - static defaultProps = { - fields: merge({}, defaultFields), - disabledFields: {}, - }; - - static contextType = AppContext; - - constructor(props, context) { - super(props, context); - - const { fields, disabledFields } = props; - const { isCloudEnabled } = context; - - // Connection mode should default to "proxy" in cloud - const defaultMode = isCloudEnabled ? PROXY_MODE : SNIFF_MODE; - const fieldsState = merge({}, { ...defaultFields, mode: defaultMode }, fields); - - this.generateId = htmlIdGenerator(); - this.state = { - localSeedErrors: [], - seedInput: '', - fields: fieldsState, - disabledFields, - fieldsErrors: this.getFieldsErrors(fieldsState), - areErrorsVisible: false, - isRequestVisible: false, - }; - } - - toggleRequest = () => { - this.setState(({ isRequestVisible }) => ({ - isRequestVisible: !isRequestVisible, - })); - }; - - getFieldsErrors(fields, seedInput = '') { - const { name, seeds, mode, proxyAddress, serverName } = fields; - const { isCloudEnabled } = this.context; - - return { - name: validateName(name), - seeds: mode === SNIFF_MODE ? validateSeeds(seeds, seedInput) : null, - proxyAddress: mode === PROXY_MODE ? validateProxy(proxyAddress) : null, - // server name is only required in cloud when proxy mode is enabled - serverName: isCloudEnabled && mode === PROXY_MODE ? validateServerName(serverName) : null, - }; - } - - onFieldsChange = (changedFields) => { - this.setState(({ fields: prevFields, seedInput }) => { - const newFields = { - ...prevFields, - ...changedFields, - }; - return { - fields: newFields, - fieldsErrors: this.getFieldsErrors(newFields, seedInput), - }; - }); - }; - - getAllFields() { - const { - fields: { - name, - mode, - seeds, - nodeConnections, - proxyAddress, - proxySocketConnections, - serverName, - skipUnavailable, - }, - } = this.state; - const { fields } = this.props; - - let modeSettings; - - if (mode === PROXY_MODE) { - modeSettings = { - proxyAddress, - proxySocketConnections, - serverName, - }; - } else { - modeSettings = { - seeds, - nodeConnections, - }; - } - - return { - name, - skipUnavailable, - mode, - hasDeprecatedProxySetting: fields.hasDeprecatedProxySetting, - ...modeSettings, - }; - } - - save = () => { - const { save } = this.props; - - if (this.hasErrors()) { - this.setState({ - areErrorsVisible: true, - }); - return; - } - - const cluster = this.getAllFields(); - save(cluster); - }; - - onCreateSeed = (newSeed) => { - // If the user just hit enter without typing anything, treat it as a no-op. - if (!newSeed) { - return; - } - - const localSeedErrors = validateSeed(newSeed); - - if (localSeedErrors.length !== 0) { - this.setState({ - localSeedErrors, - }); - - // Return false to explicitly reject the user's input. - return false; - } - - const { - fields: { seeds }, - } = this.state; - - const newSeeds = seeds.slice(0); - newSeeds.push(newSeed.toLowerCase()); - this.onFieldsChange({ seeds: newSeeds }); - }; - - onSeedsInputChange = (seedInput) => { - if (!seedInput) { - // If empty seedInput ("") don't do anything. This happens - // right after a seed is created. - return; - } - - this.setState(({ fields, localSeedErrors }) => { - const { seeds } = fields; - - // Allow typing to clear the errors, but not to add new ones. - const errors = !seedInput || validateSeed(seedInput).length === 0 ? [] : localSeedErrors; - - // EuiComboBox internally checks for duplicates and prevents calling onCreateOption if the - // input is a duplicate. So we need to surface this error here instead. - const isDuplicate = seeds.includes(seedInput); - - if (isDuplicate) { - errors.push( - i18n.translate('xpack.remoteClusters.remoteClusterForm.localSeedError.duplicateMessage', { - defaultMessage: `Duplicate seed nodes aren't allowed.`, - }) - ); - } - - return { - localSeedErrors: errors, - fieldsErrors: this.getFieldsErrors(fields, seedInput), - seedInput, - }; - }); - }; - - onSeedsChange = (seeds) => { - this.onFieldsChange({ seeds: seeds.map(({ label }) => label) }); - }; - - onSkipUnavailableChange = (e) => { - const skipUnavailable = e.target.checked; - this.onFieldsChange({ skipUnavailable }); - }; - - resetToDefault = (fieldName) => { - this.onFieldsChange({ - [fieldName]: defaultFields[fieldName], - }); - }; - - hasErrors = () => { - const { fieldsErrors, localSeedErrors } = this.state; - const errorValues = Object.values(fieldsErrors); - const hasErrors = errorValues.some((error) => error != null) || localSeedErrors.length; - return hasErrors; - }; - - renderSniffModeSettings() { - const { - areErrorsVisible, - fields: { seeds, nodeConnections }, - fieldsErrors: { seeds: errorsSeeds }, - localSeedErrors, - } = this.state; - - // Show errors if there is a general form error or local errors. - const areFormErrorsVisible = Boolean(areErrorsVisible && errorsSeeds); - const showErrors = areFormErrorsVisible || localSeedErrors.length !== 0; - const errors = areFormErrorsVisible ? localSeedErrors.concat(errorsSeeds) : localSeedErrors; - - const formattedSeeds = seeds.map((seed) => ({ label: seed })); - - return ( - <> - - } - helpText={ - - - - ), - }} - /> - } - isInvalid={showErrors} - error={errors} - fullWidth - > - - - - - } - helpText={ - - } - fullWidth - > - - this.onFieldsChange({ nodeConnections: Number(e.target.value) || null }) - } - fullWidth - /> - - - ); - } - - renderProxyModeSettings() { - const { - areErrorsVisible, - fields: { proxyAddress, proxySocketConnections, serverName }, - fieldsErrors: { proxyAddress: errorProxyAddress, serverName: errorServerName }, - } = this.state; - - const { isCloudEnabled } = this.context; - - return ( - <> - - } - helpText={ - - } - isInvalid={Boolean(areErrorsVisible && errorProxyAddress)} - error={errorProxyAddress} - fullWidth - > - this.onFieldsChange({ proxyAddress: e.target.value })} - isInvalid={Boolean(areErrorsVisible && errorProxyAddress)} - data-test-subj="remoteClusterFormProxyAddressInput" - fullWidth - /> - - - - ) : ( - - ) - } - helpText={ - - - - ), - }} - /> - } - fullWidth - > - this.onFieldsChange({ serverName: e.target.value })} - isInvalid={Boolean(areErrorsVisible && errorServerName)} - fullWidth - /> - - - - } - helpText={ - - } - fullWidth - > - - this.onFieldsChange({ proxySocketConnections: Number(e.target.value) || null }) - } - fullWidth - /> - - - ); - } - - renderMode() { - const { - fields: { mode }, - } = this.state; - - const { isCloudEnabled } = this.context; - - return ( - -

- -

- - } - description={ - <> - - - - } - checked={mode === PROXY_MODE} - data-test-subj="remoteClusterFormConnectionModeToggle" - onChange={(e) => - this.onFieldsChange({ mode: e.target.checked ? PROXY_MODE : SNIFF_MODE }) - } - /> - - {isCloudEnabled && mode === PROXY_MODE ? ( - <> - - - } - > - - - - ), - searchString: ( - - - - ), - }} - /> - - - ) : null} - - } - fullWidth - > - {mode === PROXY_MODE ? this.renderProxyModeSettings() : this.renderSniffModeSettings()} -
- ); - } - - renderSkipUnavailable() { - const { - fields: { skipUnavailable }, - } = this.state; - - return ( - -

- -

- - } - description={ - -

- - - - ), - learnMoreLink: ( - - - - ), - }} - /> -

-
- } - fullWidth - > - { - this.resetToDefault('skipUnavailable'); - }} - > - - - ) : null - } - > - - -
- ); - } - - renderActions() { - const { isSaving, cancel } = this.props; - const { areErrorsVisible, isRequestVisible } = this.state; - - if (isSaving) { - return ( - - - - - - - - - - - - ); - } - - let cancelButton; - - if (cancel) { - cancelButton = ( - - - - - - ); - } - - const isSaveDisabled = areErrorsVisible && this.hasErrors(); - - return ( - - - - - - - - - - {cancelButton} - - - - - - {isRequestVisible ? ( - - ) : ( - - )} - - - - ); - } - - renderSavingFeedback() { - if (this.props.isSaving) { - return ( - - - - ); - } - - return null; - } - - renderSaveErrorFeedback() { - const { saveError } = this.props; - - if (saveError) { - const { message, cause } = saveError; - - let errorBody; - - if (cause && Array.isArray(cause)) { - if (cause.length === 1) { - errorBody =

{cause[0]}

; - } else { - errorBody = ( -
    - {cause.map((causeValue) => ( -
  • {causeValue}
  • - ))} -
- ); - } - } - - return ( - - - {errorBody} - - - - - ); - } - - return null; - } - - renderErrors = () => { - const { - areErrorsVisible, - fieldsErrors: { name: errorClusterName, seeds: errorsSeeds, proxyAddress: errorProxyAddress }, - localSeedErrors, - } = this.state; - - const hasErrors = this.hasErrors(); - - if (!areErrorsVisible || !hasErrors) { - return null; - } - - const errorExplanations = []; - - if (errorClusterName) { - errorExplanations.push({ - key: 'nameExplanation', - field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage', { - defaultMessage: 'The "Name" field is invalid.', - }), - error: errorClusterName, - }); - } - - if (errorsSeeds) { - errorExplanations.push({ - key: 'seedsExplanation', - field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage', { - defaultMessage: 'The "Seed nodes" field is invalid.', - }), - error: errorsSeeds, - }); - } - - if (localSeedErrors && localSeedErrors.length) { - errorExplanations.push({ - key: 'localSeedExplanation', - field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage', { - defaultMessage: 'The "Seed nodes" field is invalid.', - }), - error: localSeedErrors.join(' '), - }); - } - - if (errorProxyAddress) { - errorExplanations.push({ - key: 'seedsExplanation', - field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage', { - defaultMessage: 'The "Proxy address" field is invalid.', - }), - error: errorProxyAddress, - }); - } - - const messagesToBeRendered = errorExplanations.length && ( - -
- {errorExplanations.map(({ key, field, error }) => ( -
-
{field}
-
{error}
-
- ))} -
-
- ); - - return ( - - - - - - } - color="danger" - iconType="cross" - /> - {messagesToBeRendered} - - ); - }; - - render() { - const { - disabledFields: { name: disabledName }, - } = this.props; - - const { - isRequestVisible, - areErrorsVisible, - fields: { name }, - fieldsErrors: { name: errorClusterName }, - } = this.state; - - return ( - - {this.renderSaveErrorFeedback()} - - - -

- -

- - } - description={ - - } - fullWidth - > - - } - helpText={ - - } - error={errorClusterName} - isInvalid={Boolean(areErrorsVisible && errorClusterName)} - fullWidth - > - this.onFieldsChange({ name: e.target.value })} - fullWidth - disabled={disabledName} - data-test-subj="remoteClusterFormNameInput" - /> - -
- - {this.renderMode()} - - {this.renderSkipUnavailable()} -
- - {this.renderErrors()} - - - - {this.renderActions()} - - {this.renderSavingFeedback()} - - {isRequestVisible ? ( - this.setState({ isRequestVisible: false })} - /> - ) : null} -
- ); - } -} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.test.js b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.test.js deleted file mode 100644 index 2ae16b8ca7cbf..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.test.js +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { mountWithIntl, renderWithIntl } from '@kbn/test/jest'; -import { findTestSubject, takeMountedSnapshot } from '@elastic/eui/lib/test'; -import { RemoteClusterForm } from './remote_cluster_form'; - -// Make sure we have deterministic aria IDs. -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - htmlIdGenerator: (prefix = 'staticGenerator') => (suffix = 'staticId') => `${prefix}_${suffix}`, -})); - -describe('RemoteClusterForm', () => { - test(`renders untouched state`, () => { - const component = renderWithIntl( {}} />); - expect(component).toMatchSnapshot(); - }); - - describe('proxy mode', () => { - test('renders correct connection settings when user enables proxy mode', () => { - const component = mountWithIntl( {}} />); - - findTestSubject(component, 'remoteClusterFormConnectionModeToggle').simulate('click'); - - expect(component).toMatchSnapshot(); - }); - }); - - describe('validation', () => { - test('renders invalid state and a global form error when the user tries to submit an invalid form', () => { - const component = mountWithIntl( {}} />); - - findTestSubject(component, 'remoteClusterFormSaveButton').simulate('click'); - - const fieldsSnapshot = [ - 'remoteClusterFormNameFormRow', - 'remoteClusterFormSeedNodesFormRow', - 'remoteClusterFormSkipUnavailableFormRow', - 'remoteClusterFormGlobalError', - ].map((testSubject) => { - const mountedField = findTestSubject(component, testSubject); - return takeMountedSnapshot(mountedField); - }); - - expect(fieldsSnapshot).toMatchSnapshot(); - }); - }); -}); diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.tsx new file mode 100644 index 0000000000000..9f6eee757c755 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.tsx @@ -0,0 +1,629 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Component, Fragment } from 'react'; +import { merge } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiDescribedFormGroup, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiLink, + EuiLoadingKibana, + EuiLoadingSpinner, + EuiOverlayMask, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTitle, + EuiDelayRender, + EuiScreenReaderOnly, + htmlIdGenerator, + EuiSwitchEvent, +} from '@elastic/eui'; + +import { Cluster } from '../../../../../common/lib'; +import { SNIFF_MODE, PROXY_MODE } from '../../../../../common/constants'; + +import { AppContext, Context } from '../../../app_context'; + +import { skippingDisconnectedClustersUrl } from '../../../services/documentation'; + +import { RequestFlyout } from './request_flyout'; +import { ConnectionMode } from './components'; +import { + ClusterErrors, + convertCloudUrlToProxyConnection, + convertProxyConnectionToCloudUrl, + validateCluster, +} from './validators'; +import { isCloudUrlEnabled } from './validators/validate_cloud_url'; + +const defaultClusterValues: Cluster = { + name: '', + seeds: [], + skipUnavailable: false, + nodeConnections: 3, + proxyAddress: '', + proxySocketConnections: 18, + serverName: '', +}; + +const ERROR_TITLE_ID = 'removeClustersErrorTitle'; +const ERROR_LIST_ID = 'removeClustersErrorList'; + +interface Props { + save: (cluster: Cluster) => void; + cancel?: () => void; + isSaving?: boolean; + saveError?: any; + cluster?: Cluster; +} + +export type FormFields = Cluster & { cloudUrl: string; cloudUrlEnabled: boolean }; + +interface State { + fields: FormFields; + fieldsErrors: ClusterErrors; + areErrorsVisible: boolean; + isRequestVisible: boolean; +} + +export class RemoteClusterForm extends Component { + static defaultProps = { + fields: merge({}, defaultClusterValues), + }; + + static contextType = AppContext; + private readonly generateId: (idSuffix?: string) => string; + + constructor(props: Props, context: Context) { + super(props, context); + + const { cluster } = props; + const { isCloudEnabled } = context; + + // Connection mode should default to "proxy" in cloud + const defaultMode = isCloudEnabled ? PROXY_MODE : SNIFF_MODE; + const fieldsState: FormFields = merge( + {}, + { + ...defaultClusterValues, + mode: defaultMode, + cloudUrl: convertProxyConnectionToCloudUrl(cluster), + cloudUrlEnabled: isCloudEnabled && isCloudUrlEnabled(cluster), + }, + cluster + ); + + this.generateId = htmlIdGenerator(); + this.state = { + fields: fieldsState, + fieldsErrors: validateCluster(fieldsState, isCloudEnabled), + areErrorsVisible: false, + isRequestVisible: false, + }; + } + + toggleRequest = () => { + this.setState(({ isRequestVisible }) => ({ + isRequestVisible: !isRequestVisible, + })); + }; + + onFieldsChange = (changedFields: Partial) => { + const { isCloudEnabled } = this.context; + + // when cloudUrl changes, fill proxy address and server name + const { cloudUrl } = changedFields; + if (cloudUrl) { + const { proxyAddress, serverName } = convertCloudUrlToProxyConnection(cloudUrl); + changedFields = { + ...changedFields, + proxyAddress, + serverName, + }; + } + + this.setState(({ fields: prevFields }) => { + const newFields = { + ...prevFields, + ...changedFields, + }; + return { + fields: newFields, + fieldsErrors: validateCluster(newFields, isCloudEnabled), + }; + }); + }; + + getCluster(): Cluster { + const { + fields: { + name, + mode, + seeds, + nodeConnections, + proxyAddress, + proxySocketConnections, + serverName, + skipUnavailable, + }, + } = this.state; + const { cluster } = this.props; + + let modeSettings; + + if (mode === PROXY_MODE) { + modeSettings = { + proxyAddress, + proxySocketConnections, + serverName, + }; + } else { + modeSettings = { + seeds, + nodeConnections, + }; + } + + return { + name, + skipUnavailable, + mode, + hasDeprecatedProxySetting: cluster?.hasDeprecatedProxySetting, + ...modeSettings, + }; + } + + save = () => { + const { save } = this.props; + + if (this.hasErrors()) { + this.setState({ + areErrorsVisible: true, + }); + return; + } + + const cluster = this.getCluster(); + save(cluster); + }; + + onSkipUnavailableChange = (e: EuiSwitchEvent) => { + const skipUnavailable = e.target.checked; + this.onFieldsChange({ skipUnavailable }); + }; + + resetToDefault = (fieldName: keyof Cluster) => { + this.onFieldsChange({ + [fieldName]: defaultClusterValues[fieldName], + }); + }; + + hasErrors = () => { + const { fieldsErrors } = this.state; + const errorValues = Object.values(fieldsErrors); + return errorValues.some((error) => error != null); + }; + + renderSkipUnavailable() { + const { + fields: { skipUnavailable }, + } = this.state; + + return ( + +

+ +

+ + } + description={ + +

+ + + + ), + learnMoreLink: ( + + + + ), + }} + /> +

+
+ } + fullWidth + > + { + this.resetToDefault('skipUnavailable'); + }} + > + + + ) : null + } + > + + +
+ ); + } + + renderActions() { + const { isSaving, cancel } = this.props; + const { areErrorsVisible, isRequestVisible } = this.state; + + if (isSaving) { + return ( + + + + + + + + + + + + ); + } + + let cancelButton; + + if (cancel) { + cancelButton = ( + + + + + + ); + } + + const isSaveDisabled = areErrorsVisible && this.hasErrors(); + + return ( + + + + + + + + + + {cancelButton} + + + + + + {isRequestVisible ? ( + + ) : ( + + )} + + + + ); + } + + renderSavingFeedback() { + if (this.props.isSaving) { + return ( + + + + ); + } + + return null; + } + + renderSaveErrorFeedback() { + const { saveError } = this.props; + + if (saveError) { + const { message, cause } = saveError; + + let errorBody; + + if (cause && Array.isArray(cause)) { + if (cause.length === 1) { + errorBody =

{cause[0]}

; + } else { + errorBody = ( +
    + {cause.map((causeValue) => ( +
  • {causeValue}
  • + ))} +
+ ); + } + } + + return ( + + + {errorBody} + + + + + ); + } + + return null; + } + + renderErrors = () => { + const { + areErrorsVisible, + fieldsErrors: { + name: errorClusterName, + seeds: errorsSeeds, + proxyAddress: errorProxyAddress, + serverName: errorServerName, + cloudUrl: errorCloudUrl, + }, + } = this.state; + + const hasErrors = this.hasErrors(); + + if (!areErrorsVisible || !hasErrors) { + return null; + } + + const errorExplanations = []; + + if (errorClusterName) { + errorExplanations.push({ + key: 'nameExplanation', + field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage', { + defaultMessage: 'The "Name" field is invalid.', + }), + error: errorClusterName, + }); + } + + if (errorsSeeds) { + errorExplanations.push({ + key: 'seedsExplanation', + field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage', { + defaultMessage: 'The "Seed nodes" field is invalid.', + }), + error: errorsSeeds, + }); + } + + if (errorProxyAddress) { + errorExplanations.push({ + key: 'proxyAddressExplanation', + field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage', { + defaultMessage: 'The "Proxy address" field is invalid.', + }), + error: errorProxyAddress, + }); + } + + if (errorServerName) { + errorExplanations.push({ + key: 'serverNameExplanation', + field: i18n.translate( + 'xpack.remoteClusters.remoteClusterForm.inputServerNameErrorMessage', + { + defaultMessage: 'The "Server name" field is invalid.', + } + ), + error: errorServerName, + }); + } + + if (errorCloudUrl) { + errorExplanations.push({ + key: 'cloudUrlExplanation', + field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputcloudUrlErrorMessage', { + defaultMessage: 'The "Elasticsearch endpoint URL" field is invalid.', + }), + error: errorCloudUrl, + }); + } + + const messagesToBeRendered = errorExplanations.length && ( + +
+ {errorExplanations.map(({ key, field, error }) => ( +
+
{field}
+
{error}
+
+ ))} +
+
+ ); + + return ( + + + + + + } + color="danger" + iconType="cross" + /> + {messagesToBeRendered} + + ); + }; + + render() { + const { isRequestVisible, areErrorsVisible, fields, fieldsErrors } = this.state; + const { name: errorClusterName } = fieldsErrors; + const { cluster } = this.props; + const isNew = !cluster; + return ( + + {this.renderSaveErrorFeedback()} + + + +

+ +

+ + } + description={ + + } + fullWidth + > + + } + helpText={ + + } + error={errorClusterName} + isInvalid={Boolean(areErrorsVisible && errorClusterName)} + fullWidth + > + this.onFieldsChange({ name: e.target.value })} + fullWidth + disabled={!isNew} + data-test-subj="remoteClusterFormNameInput" + /> + +
+ + + + {this.renderSkipUnavailable()} +
+ + {this.renderErrors()} + + + + {this.renderActions()} + + {this.renderSavingFeedback()} + + {isRequestVisible ? ( + this.setState({ isRequestVisible: false })} + /> + ) : null} +
+ ); + } +} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx index 4e402b8b55a5b..2bcedc2bce458 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/request_flyout.tsx @@ -24,13 +24,13 @@ import { Cluster, serializeCluster } from '../../../../../common/lib'; interface Props { close: () => void; - name: string; cluster: Cluster; } export class RequestFlyout extends PureComponent { render() { - const { name, close, cluster } = this.props; + const { close, cluster } = this.props; + const { name } = cluster; const endpoint = 'PUT _cluster/settings'; const payload = JSON.stringify(serializeCluster(cluster), null, 2); const request = `${endpoint}\n${payload}`; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts index 67a5d8f727f3e..6f3956a19f6a0 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/index.ts @@ -10,3 +10,10 @@ export { validateProxy } from './validate_proxy'; export { validateSeeds } from './validate_seeds'; export { validateSeed } from './validate_seed'; export { validateServerName } from './validate_server_name'; +export { validateCluster, ClusterErrors } from './validate_cluster'; +export { + isCloudUrlEnabled, + validateCloudUrl, + convertProxyConnectionToCloudUrl, + convertCloudUrlToProxyConnection, +} from './validate_cloud_url'; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts new file mode 100644 index 0000000000000..599706ba85b02 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + isCloudUrlEnabled, + validateCloudUrl, + convertCloudUrlToProxyConnection, + convertProxyConnectionToCloudUrl, + i18nTexts, +} from './validate_cloud_url'; + +describe('Cloud url', () => { + describe('validation', () => { + it('errors when the url is empty', () => { + const actual = validateCloudUrl(''); + expect(actual).toBe(i18nTexts.urlEmpty); + }); + + it('errors when the url is invalid', () => { + const actual = validateCloudUrl('invalid%url'); + expect(actual).toBe(i18nTexts.urlInvalid); + }); + }); + + describe('is cloud url', () => { + it('true for a new cluster', () => { + const actual = isCloudUrlEnabled(); + expect(actual).toBe(true); + }); + + it('true when proxy connection is empty', () => { + const actual = isCloudUrlEnabled({ name: 'test', proxyAddress: '', serverName: '' }); + expect(actual).toBe(true); + }); + + it('true when proxy address is the same as server name and default port', () => { + const actual = isCloudUrlEnabled({ + name: 'test', + proxyAddress: 'some-proxy:9400', + serverName: 'some-proxy', + }); + expect(actual).toBe(true); + }); + it('false when proxy address is the same as server name but not default port', () => { + const actual = isCloudUrlEnabled({ + name: 'test', + proxyAddress: 'some-proxy:1234', + serverName: 'some-proxy', + }); + expect(actual).toBe(false); + }); + it('true when proxy address is not the same as server name', () => { + const actual = isCloudUrlEnabled({ + name: 'test', + proxyAddress: 'some-proxy:9400', + serverName: 'some-server-name', + }); + expect(actual).toBe(false); + }); + }); + describe('conversion from cloud url', () => { + it('empty url to empty proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection(''); + expect(actual).toEqual({ proxyAddress: '', serverName: '' }); + }); + + it('url with protocol and port to proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('http://test.com:1234'); + expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); + }); + + it('url with protocol and no port to proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('http://test.com'); + expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); + }); + + it('url with no protocol to proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('test.com'); + expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); + }); + it('invalid url to empty proxy connection values', () => { + const actual = convertCloudUrlToProxyConnection('invalid%url'); + expect(actual).toEqual({ proxyAddress: '', serverName: '' }); + }); + }); + + describe('conversion to cloud url', () => { + it('empty proxy address to empty cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: '', + serverName: 'test', + }); + expect(actual).toEqual(''); + }); + + it('empty server name to empty cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: 'test', + serverName: '', + }); + expect(actual).toEqual(''); + }); + + it('different proxy address and server name to empty cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: 'test', + serverName: 'another-test', + }); + expect(actual).toEqual(''); + }); + + it('valid proxy connection to cloud url', () => { + const actual = convertProxyConnectionToCloudUrl({ + name: 'test', + proxyAddress: 'test-proxy:9400', + serverName: 'test-proxy', + }); + expect(actual).toEqual('test-proxy'); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx new file mode 100644 index 0000000000000..1f4862f0113e7 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cloud_url.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Cluster } from '../../../../../../common/lib'; +import { isAddressValid } from './validate_address'; + +export const i18nTexts = { + urlEmpty: ( + + ), + urlInvalid: ( + + ), +}; + +const CLOUD_DEFAULT_PROXY_PORT = '9400'; +const EMPTY_PROXY_VALUES = { proxyAddress: '', serverName: '' }; +const PROTOCOL_REGEX = new RegExp(/^https?:\/\//); + +export const isCloudUrlEnabled = (cluster?: Cluster): boolean => { + // enable cloud url for new clusters + if (!cluster) { + return true; + } + const { proxyAddress, serverName } = cluster; + if (!proxyAddress && !serverName) { + return true; + } + const portParts = (proxyAddress ?? '').split(':'); + const proxyAddressWithoutPort = portParts[0]; + const port = portParts[1]; + return port === CLOUD_DEFAULT_PROXY_PORT && proxyAddressWithoutPort === serverName; +}; + +const formatUrl = (url: string) => { + url = (url ?? '').trim().toLowerCase(); + // delete http(s):// protocol string if any + url = url.replace(PROTOCOL_REGEX, ''); + return url; +}; + +export const convertProxyConnectionToCloudUrl = (cluster?: Cluster): string => { + if (!isCloudUrlEnabled(cluster)) { + return ''; + } + return cluster?.serverName ?? ''; +}; +export const convertCloudUrlToProxyConnection = ( + cloudUrl: string = '' +): { proxyAddress: string; serverName: string } => { + cloudUrl = formatUrl(cloudUrl); + if (!cloudUrl || !isAddressValid(cloudUrl)) { + return EMPTY_PROXY_VALUES; + } + const address = cloudUrl.split(':')[0]; + return { proxyAddress: `${address}:${CLOUD_DEFAULT_PROXY_PORT}`, serverName: address }; +}; + +export const validateCloudUrl = (cloudUrl: string): JSX.Element | null => { + if (!cloudUrl) { + return i18nTexts.urlEmpty; + } + cloudUrl = formatUrl(cloudUrl); + if (!isAddressValid(cloudUrl)) { + return i18nTexts.urlInvalid; + } + return null; +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx new file mode 100644 index 0000000000000..e0fa434f21d5c --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_cluster.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { validateName } from './validate_name'; +import { PROXY_MODE, SNIFF_MODE } from '../../../../../../common/constants'; +import { validateSeeds } from './validate_seeds'; +import { validateProxy } from './validate_proxy'; +import { validateServerName } from './validate_server_name'; +import { validateCloudUrl } from './validate_cloud_url'; +import { FormFields } from '../remote_cluster_form'; + +type ClusterError = JSX.Element | null; + +export interface ClusterErrors { + name?: ClusterError; + seeds?: ClusterError; + proxyAddress?: ClusterError; + serverName?: ClusterError; + cloudUrl?: ClusterError; +} +export const validateCluster = (fields: FormFields, isCloudEnabled: boolean): ClusterErrors => { + const { name, seeds = [], mode, proxyAddress, serverName, cloudUrlEnabled, cloudUrl } = fields; + + return { + name: validateName(name), + seeds: mode === SNIFF_MODE ? validateSeeds(seeds) : null, + proxyAddress: !cloudUrlEnabled && mode === PROXY_MODE ? validateProxy(proxyAddress) : null, + // server name is only required in cloud when proxy mode is enabled + serverName: + !cloudUrlEnabled && isCloudEnabled && mode === PROXY_MODE + ? validateServerName(serverName) + : null, + cloudUrl: cloudUrlEnabled ? validateCloudUrl(cloudUrl) : null, + }; +}; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.ts b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.ts deleted file mode 100644 index a5b3656b36de5..0000000000000 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -import { isAddressValid, isPortValid } from './validate_address'; - -export function validateSeed(seed?: string): string[] { - const errors: string[] = []; - - if (!seed) { - return errors; - } - - const isValid = isAddressValid(seed); - - if (!isValid) { - errors.push( - i18n.translate( - 'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage', - { - defaultMessage: - 'Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. ' + - 'Hosts can only consist of letters, numbers, and dashes.', - } - ) - ); - } - - if (!isPortValid(seed)) { - errors.push( - i18n.translate('xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage', { - defaultMessage: 'A port is required.', - }) - ); - } - - return errors; -} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.tsx b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.tsx new file mode 100644 index 0000000000000..4863dff5ec337 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/validators/validate_seed.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { isAddressValid, isPortValid } from './validate_address'; + +export function validateSeed(seed?: string): JSX.Element[] { + const errors: JSX.Element[] = []; + + if (!seed) { + return errors; + } + + const isValid = isAddressValid(seed); + + if (!isValid) { + errors.push( + + ); + } + + if (!isPortValid(seed)) { + errors.push( + + ); + } + + return errors; +} diff --git a/x-pack/plugins/remote_clusters/public/application/sections/index.d.ts b/x-pack/plugins/remote_clusters/public/application/sections/index.d.ts new file mode 100644 index 0000000000000..ab0f579c1a415 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/application/sections/index.d.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. + */ +import { ComponentType } from 'react'; + +export declare const RemoteClusterEdit: ComponentType; +export declare const RemoteClusterAdd: ComponentType; +export declare const RemoteClusterList: ComponentType; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js index 6ee6bd6d87d58..124d2d42afb78 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js @@ -73,7 +73,7 @@ export class RemoteClusterAdd extends PureComponent { description={ } /> diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js index c68dd7ab10aa7..18ee2e2b3875d 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js @@ -27,10 +27,6 @@ import { getRouter, redirect } from '../../services'; import { setBreadcrumbs } from '../../services/breadcrumb'; import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components'; -const disabledFields = { - name: true, -}; - export class RemoteClusterEdit extends Component { static propTypes = { isLoading: PropTypes.bool, @@ -202,8 +198,7 @@ export class RemoteClusterEdit extends Component { ) : null} Store; diff --git a/x-pack/plugins/remote_clusters/public/assets/cloud_screenshot.png b/x-pack/plugins/remote_clusters/public/assets/cloud_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..f6c9302ef76eac6184fad0eb6931558c94980e54 GIT binary patch literal 197089 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 z z)6xu)kFX`3u3CPjPSx34pfOqRZ2chZoU`jFJ!eWIFs{sS z-|p4HHBmJ$+9In!Yraq|#f@Xy8BR#9j{=&FiiJs-+Aemvi!Lk4&AKn;U1VFrsb7?sO@AAPrh) za#nBpaiGb`NyB7eS!889T#HLJi5HV8+7?aLLQ6z#b+LlzopiiLpe6sMnn8!6&X;0` zdvv>?Lj~Bf_!NtC)<>)K3Asm)aYkwsdrpauCY)z}_WaCVz+fo)~$adQ+ z5GR|1M%#73NIQWLRh@!aOStH$3KsFuQU6jd4&OUjNOlsBHDti~0lgsQrP0G+3Rlqf ztaCAEo4Dvpm+BZ6G>J*W?Wilxo^ax`DlCNeQkyq<6EgJZ8G%w1YouZ%3!sHS$o`U? zmO}N~Hb9$f7CWPzZ+XB}F;&t$tQh_U{n=Zw4DdZ%DWf*WZEy1RUVEmn&2)Weg%9*_ zF>m=TZqr+&$%zPWvNx)7d{D13L+>{oLJ-5^m^BzmwFa}8ZMGO=(Y1Tb{8XWNOQO)6 zTwUTjIq<#Nma+fBJ|cGqEH0DL zS?;GB{V4}QQGbarqjrD7b_XZRMx(eK2gt4TWu8u_(>iuiO`kKV8oOQYZh#rn{m((o zut|p8miGuI?eY+kh1KP3jOsQ%p8}HdyuK~NMp6ycZU8bUB_}md+F2L2Ok0sR`^0S^ ztf|kR4(|%6y~VCe7jWC`1f(qPX4k8F1fHv>&TUi{=)RA zofSraSC$vMO^0l0+{l$X2<-_jmC#m$mPNS0R3*_TnPFk|vfjw?qcNlL9@dU7-yk<5 z|NB(7wt4!iQ!1mY+RP%5f{ovDMT?ie^UUh(hC`eV#OcZoRgi5jQ}gVF8tBzzgO)(;YypRr!V<)1zP1ntgiE=6-pUD)){k3#?X%Xd6Xxsgl z8n<2U`P^mo2P*@^a+ba2kH}6k4>yq!Ieav?9PJ
;gLBW7KrTwnn@iuets>=U!`- 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

M|4!~-3cgu7U#8Pr4HL9(7d^4r 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 Kq*(UloBsm`DHQVn literal 0 HcmV?d00001 diff --git a/x-pack/plugins/remote_clusters/public/plugin.ts b/x-pack/plugins/remote_clusters/public/plugin.ts index 107d4e127d1b5..540a8b40a6208 100644 --- a/x-pack/plugins/remote_clusters/public/plugin.ts +++ b/x-pack/plugins/remote_clusters/public/plugin.ts @@ -60,13 +60,14 @@ export class RemoteClustersUIPlugin initNotification(toasts, fatalErrors); initHttp(http); - const isCloudEnabled = Boolean(cloud?.isCloudEnabled); + const isCloudEnabled: boolean = Boolean(cloud?.isCloudEnabled); + const cloudBaseUrl: string = cloud?.baseUrl ?? ''; const { renderApp } = await import('./application'); const unmountAppCallback = await renderApp( element, i18nContext, - { isCloudEnabled }, + { isCloudEnabled, cloudBaseUrl }, history ); diff --git a/x-pack/plugins/remote_clusters/tsconfig.json b/x-pack/plugins/remote_clusters/tsconfig.json index 0bee6300cf0b2..9dc7926bd62ea 100644 --- a/x-pack/plugins/remote_clusters/tsconfig.json +++ b/x-pack/plugins/remote_clusters/tsconfig.json @@ -8,10 +8,12 @@ "declarationMap": true }, "include": [ + "__jest__/**/*", "common/**/*", "fixtures/**/*", "public/**/*", "server/**/*", + "../../../typings/**/*", ], "references": [ { "path": "../../../src/core/tsconfig.json" }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a1bb98669cd70..e42b87f388473 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -16756,10 +16756,6 @@ "xpack.remoteClusters.addTitle": "リモートクラスターを追加", "xpack.remoteClusters.appName": "リモートクラスター", "xpack.remoteClusters.appTitle": "リモートクラスター", - "xpack.remoteClusters.cloudClusterInformationDescription": "クラスターのプロキシアドレスとサーバー名を見つけるには、デプロイメニューの{security}ページに移動し、{searchString}を検索します。", - "xpack.remoteClusters.cloudClusterInformationTitle": "Elasticsearch Cloudデプロイのプロキシモードを使用", - "xpack.remoteClusters.cloudClusterSearchDescription": "リモートクラスターパラメーター", - "xpack.remoteClusters.cloudClusterSecurityDescription": "セキュリティ", "xpack.remoteClusters.configuredByNodeWarningTitle": "このリモートクラスターはノードの elasticsearch.yml 構成ファイルで定義されているため、編集または削除できません。", "xpack.remoteClusters.connectedStatus.connectedAriaLabel": "接続済み", "xpack.remoteClusters.connectedStatus.notConnectedAriaLabel": "未接続", @@ -16833,7 +16829,6 @@ "xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "サーバー名", "xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "リモートクラスターごとに開くソケット接続の数。", "xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "リクエストを非表示", - "xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage": "「シードノード」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "「名前」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "「プロキシアドレス」フィールドが無効です。", "xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "「シードノード」フィールドが無効です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1fa26ba983071..623e127bd84b0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -16981,10 +16981,6 @@ "xpack.remoteClusters.addTitle": "添加远程集群", "xpack.remoteClusters.appName": "远程集群", "xpack.remoteClusters.appTitle": "远程集群", - "xpack.remoteClusters.cloudClusterInformationDescription": "要查找您的集群的代理地址和服务器名称,请前往部署菜单的{security}页面并搜索“{searchString}”。", - "xpack.remoteClusters.cloudClusterInformationTitle": "将代理模式用于 Elastic Cloud 部署", - "xpack.remoteClusters.cloudClusterSearchDescription": "远程集群参数", - "xpack.remoteClusters.cloudClusterSecurityDescription": "安全", "xpack.remoteClusters.configuredByNodeWarningTitle": "您无法编辑或删除此远程集群,因为它是在节点的 elasticsearch.yml 配置文件中定义的。", "xpack.remoteClusters.connectedStatus.connectedAriaLabel": "已连接", "xpack.remoteClusters.connectedStatus.notConnectedAriaLabel": "未连接", @@ -17059,7 +17055,6 @@ "xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "服务器名", "xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "每个远程集群要打开的套接字数目。", "xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "隐藏请求", - "xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage": "“种子节点”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "“名称”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "“代理地址”字段无效。", "xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "“种子节点”字段无效。", From 2510fb18c7792ece57156ee55f8c9ef3f4353a3e Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 8 Apr 2021 11:00:30 -0400 Subject: [PATCH 15/22] [Lens] Fix Chart Switcher Icon Color Contrast (#96146) (#96344) * add new lns prefixed classes to target icon colors * correct groupPosition prop on visual options * account for icon accent color contrast * switch to ternary operator Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Michael Marcialis --- x-pack/plugins/lens/public/app_plugin/app.scss | 8 +++++--- .../visual_options_popover/visual_options_popover.tsx | 2 +- .../lens/public/xy_visualization/xy_config_panel.tsx | 5 ++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index 8416577a60421..b2b63015deef3 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -30,13 +30,15 @@ .lensChartIcon__subdued { fill: $euiTextSubduedColor; - // Not great, but the easiest way to fix the gray fill when stuck in a button with a fill - // Like when selected in a button group - .euiButton--fill & { + .lnsLayerChartSwitch__item-isSelected & { fill: currentColor; } } .lensChartIcon__accent { fill: $euiColorVis0; + + .lnsLayerChartSwitch__item-isSelected & { + fill: makeGraphicContrastColor($euiColorVis0, $euiColorDarkShade); + } } diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx index fcdef86cc5d0e..b8b89f146bdc0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx @@ -83,7 +83,7 @@ export const VisualOptionsPopover: React.FC = ({ defaultMessage: 'Visual options', })} type="visualOptions" - groupPosition="right" + groupPosition="left" buttonDataTestSubj="lnsVisualOptionsButton" isDisabled={isDisabled} > diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index d7868a17bf9db..6f3017b80be1c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -98,10 +98,13 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { defaultMessage: 'Chart type', })} name="chartType" - className="eui-displayInlineBlock" + className="eui-displayInlineBlock lnsLayerChartSwitch" options={visualizationTypes .filter((t) => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) .map((t) => ({ + className: `lnsLayerChartSwitch__item ${ + layer.seriesType === t.id ? 'lnsLayerChartSwitch__item-isSelected' : '' + }`, id: t.id, label: t.label, iconType: t.icon || 'empty', From 12ffed606217192d6eb59b543d6d59d97e9ef9c9 Mon Sep 17 00:00:00 2001 From: spalger Date: Thu, 8 Apr 2021 08:13:51 -0700 Subject: [PATCH 16/22] skip entire fleet_api_integration suite to unblock es promotion (#96515) (cherry picked from commit afc1fd022e9ead8685a7e63a1b738688f5c82a44) --- x-pack/scripts/functional_tests.js | 3 ++- x-pack/test/fleet_api_integration/apis/agents_setup.ts | 3 +-- x-pack/test/fleet_api_integration/apis/epm/list.ts | 3 +-- x-pack/test/fleet_api_integration/apis/fleet_setup.ts | 3 +-- .../security_solution_endpoint_api_int/apis/artifacts/index.ts | 3 +-- 5 files changed, 6 insertions(+), 9 deletions(-) diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index ccecea04591e8..98b74a1566ce3 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -70,7 +70,8 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/reporting_api_integration/reporting_and_security.config.ts'), require.resolve('../test/reporting_api_integration/reporting_without_security.config.ts'), require.resolve('../test/security_solution_endpoint_api_int/config.ts'), - require.resolve('../test/fleet_api_integration/config.ts'), + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 + // require.resolve('../test/fleet_api_integration/config.ts'), require.resolve('../test/functional_enterprise_search/without_host_configured.config.ts'), require.resolve('../test/functional_vis_wizard/config.ts'), require.resolve('../test/search_sessions_integration/config.ts'), 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 d49bc91251b01..91d6ca0119d1d 100644 --- a/x-pack/test/fleet_api_integration/apis/agents_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/agents_setup.ts @@ -15,8 +15,7 @@ export default function (providerContext: FtrProviderContext) { const es = getService('es'); const esArchiver = getService('esArchiver'); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 - describe.skip('fleet_agents_setup', () => { + describe('fleet_agents_setup', () => { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('empty_kibana'); diff --git a/x-pack/test/fleet_api_integration/apis/epm/list.ts b/x-pack/test/fleet_api_integration/apis/epm/list.ts index 0a7002764a54c..5a991e52bdba4 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/list.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/list.ts @@ -19,8 +19,7 @@ export default function (providerContext: FtrProviderContext) { // because `this` has to point to the Mocha context // see https://mochajs.org/#arrow-functions - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 - describe.skip('EPM - list', async function () { + describe('EPM - list', async function () { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('fleet/empty_fleet_server'); 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 a82ed3f8cf22d..c9709475d182d 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts @@ -15,8 +15,7 @@ export default function (providerContext: FtrProviderContext) { const es = getService('es'); const esArchiver = getService('esArchiver'); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 - describe.skip('fleet_setup', () => { + describe('fleet_setup', () => { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('empty_kibana'); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts index 8ee028ae3f56b..e1edeb7808697 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts @@ -19,8 +19,7 @@ export default function (providerContext: FtrProviderContext) { const supertestWithoutAuth = getSupertestWithoutAuth(providerContext); let agentAccessAPIKey: string; - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 - describe.skip('artifact download', () => { + describe('artifact download', () => { const esArchiverSnapshots = [ 'endpoint/artifacts/fleet_artifacts', 'endpoint/artifacts/api_feature', From 3ee7b36bd1207e75a7daac610d3e279c42cc8b57 Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Thu, 8 Apr 2021 16:28:28 +0100 Subject: [PATCH 17/22] [ML] Excludes metadata fields from jobs caps fields service response (#96548) (#96573) --- .../ml/server/models/job_service/new_job_caps/field_service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts index 0287c2af11a7e..c6cf608fe1e0b 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -80,7 +80,7 @@ class FieldsService { if (firstKey !== undefined) { const field = fc[firstKey]; // add to the list of fields if the field type can be used by ML - if (supportedTypes.includes(field.type) === true) { + if (supportedTypes.includes(field.type) === true && field.metadata_field !== true) { fields.push({ id: k, name: k, From 7fcddeca12f80fee3a9ef2a53c70388bcd10f8cb Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 8 Apr 2021 11:33:54 -0400 Subject: [PATCH 18/22] Document SO migrations enableV2 and batchSize config options (#96290) (#96587) * document SO batchsize and migrationsv2 enablement options * use refs by name * Update docs/setup/settings.asciidoc Co-authored-by: Luke Elmers * apply Lukes suggestion * add a note that migrations.enableV2 will be removed soon * document migrations.retryAttempts * Apply suggestions from code review Co-authored-by: Kaarina Tungseth Co-authored-by: Luke Elmers Co-authored-by: Kaarina Tungseth Co-authored-by: Mikhail Shustov Co-authored-by: Luke Elmers Co-authored-by: Kaarina Tungseth --- docs/setup/settings.asciidoc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 643718b961650..90e813afad6f4 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -429,6 +429,15 @@ to display map tiles in tilemap visualizations. By default, override this parameter to use their own Tile Map Service. For example: `"https://tiles.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana"` +| `migrations.batchSize:` + | Defines the number of documents migrated at a time. The higher the value, the faster the Saved Objects migration process performs at the cost of higher memory consumption. If the migration fails due to a `circuit_breaking_exception`, set a smaller `batchSize` value. *Default: `1000`* + +| `migrations.enableV2:` + | experimental[]. Enables the new Saved Objects migration algorithm. For information about the migration algorithm, refer to <>. When `migrations v2` is stable, the setting will be removed in an upcoming release without any further notice. Setting the value to `false` causes {kib} to use the legacy migration algorithm, which shipped in 7.11 and earlier versions. *Default: `true`* + +| `migrations.retryAttempts:` + | The number of times migrations retry temporary failures, such as a network timeout, 503 status code, or `snapshot_in_progress_exception`. When upgrade migrations frequently fail after exhausting all retry attempts with a message such as `Unable to complete the [...] step after 15 attempts, terminating.`, increase the setting value. *Default: `15`* + | `newsfeed.enabled:` | Controls whether to enable the newsfeed system for the {kib} UI notification center. Set to `false` to disable the From 5cf095032e815f17e3472dc64a5dd6d0f69cac24 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 8 Apr 2021 11:05:13 -0500 Subject: [PATCH 19/22] skip flaky test. #77933 --- x-pack/test/accessibility/apps/spaces.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/spaces.ts b/x-pack/test/accessibility/apps/spaces.ts index 032186b2e90ec..41926628c2377 100644 --- a/x-pack/test/accessibility/apps/spaces.ts +++ b/x-pack/test/accessibility/apps/spaces.ts @@ -24,7 +24,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('home'); }); - it('a11y test for manage spaces menu from top nav on Kibana home', async () => { + // flaky https://github.com/elastic/kibana/issues/77933 + it.skip('a11y test for manage spaces menu from top nav on Kibana home', async () => { await PageObjects.spaceSelector.openSpacesNav(); await retry.waitFor( 'Manage spaces option visible', From d2bc43c6b343f1c258b86f5112514902f95e26e9 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Thu, 8 Apr 2021 11:33:06 -0500 Subject: [PATCH 20/22] [Dashboard] Fix Lens and TSVB chart tooltip positioning relative to global headers (#94247) (#96578) --- src/core/public/rendering/_base.scss | 14 ++++++++++++++ src/core/public/rendering/rendering_service.tsx | 1 + .../visualizations/views/timeseries/index.js | 1 + .../vis_type_xy/public/components/xy_settings.tsx | 4 +++- .../public/pie_visualization/render_function.tsx | 2 ++ .../__snapshots__/expression.test.tsx.snap | 7 +++++++ .../lens/public/xy_visualization/expression.tsx | 2 +- 7 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/core/public/rendering/_base.scss b/src/core/public/rendering/_base.scss index de13785a17f5b..ed2d9bc0b3917 100644 --- a/src/core/public/rendering/_base.scss +++ b/src/core/public/rendering/_base.scss @@ -11,6 +11,16 @@ min-height: 100%; } +#app-fixed-viewport { + pointer-events: none; + visibility: hidden; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + .app-wrapper { display: flex; flex-flow: column nowrap; @@ -35,6 +45,10 @@ @mixin kbnAffordForHeader($headerHeight) { padding-top: $headerHeight; + #app-fixed-viewport { + top: $headerHeight; + } + .euiFlyout, .euiCollapsibleNav { top: $headerHeight; diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 843f2a253f33e..787fa475c7d5f 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -52,6 +52,7 @@ export class RenderingService { {chromeHeader}

+
{bannerComponent}
{appComponent}
diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index c973b7b89893c..f9a52a9450dcb 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -149,6 +149,7 @@ export const TimeSeries = ({ tooltip={{ snap: true, type: tooltipMode === 'show_focused' ? TooltipType.Follow : TooltipType.VerticalCursor, + boundary: document.getElementById('app-fixed-viewport') ?? undefined, headerFormatter: tooltipFormatter, }} externalPointerEvents={{ tooltip: { visible: false } }} diff --git a/src/plugins/vis_type_xy/public/components/xy_settings.tsx b/src/plugins/vis_type_xy/public/components/xy_settings.tsx index 59bed0060a6a6..8922f512522a0 100644 --- a/src/plugins/vis_type_xy/public/components/xy_settings.tsx +++ b/src/plugins/vis_type_xy/public/components/xy_settings.tsx @@ -148,13 +148,15 @@ export const XYSettings: FC = ({ : headerValueFormatter && (tooltip.detailedTooltip ? undefined : ({ value }: any) => headerValueFormatter(value)); + const boundary = document.getElementById('app-fixed-viewport') ?? undefined; const tooltipProps: TooltipProps = tooltip.detailedTooltip ? { ...tooltip, + boundary, customTooltip: tooltip.detailedTooltip(headerFormatter), headerFormatter: undefined, } - : { ...tooltip, headerFormatter }; + : { ...tooltip, boundary, headerFormatter }; return (