From 80a84070e4697ef20ad7deda04c52f52a73241cd Mon Sep 17 00:00:00 2001 From: Alex Holmansky Date: Thu, 19 Mar 2020 10:17:47 -0400 Subject: [PATCH 01/13] Update workflow syntax (#60626) * Updated workflow syntax --- .github/workflows/pr-project-assigner.yml | 6 +++--- .github/workflows/project-assigner.yml | 2 +- src/plugins/data/README.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-project-assigner.yml b/.github/workflows/pr-project-assigner.yml index 3e9c86d0ee891..d8b25b980a478 100644 --- a/.github/workflows/pr-project-assigner.yml +++ b/.github/workflows/pr-project-assigner.yml @@ -13,9 +13,9 @@ jobs: with: issue-mappings: | [ - { "label": "Team:AppArch", "projectName": "kibana-app-arch", "columnId": 6173897 }, - { "label": "Feature:Lens", "projectName": "Lens", "columnId": 6219362 }, - { "label": "Team:Canvas", "projectName": "canvas", "columnId": 6187580 } + { "label": "Team:AppArch", "projectNumber": 37, "columnName": "Review in progress" }, + { "label": "Feature:Lens", "projectNumber": 32, "columnName": "In progress" }, + { "label": "Team:Canvas", "projectNumber": 38, "columnName": "Review in progress" } ] ghToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 2bc4c506fea33..30032c9a7f998 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -11,7 +11,7 @@ jobs: uses: elastic/github-actions/project-assigner@v2.0.0 id: project_assigner with: - issue-mappings: '[{"label": "Team:AppArch", "projectName": "kibana-app-arch", "columnId": 6173895}, {"label": "Feature:Lens", "projectName": "Lens", "columnId": 6219363}, {"label": "Team:Canvas", "projectName": "canvas", "columnId": 6187593}]' + issue-mappings: '[{"label": "Team:AppArch", "projectNumber": 37, "columnName": "To triage"}, {"label": "Feature:Lens", "projectNumber": 32, "columnName": "Long-term goals"}, {"label": "Team:Canvas", "projectNumber": 38, "columnName": "Inbox"}]' ghToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/plugins/data/README.md b/src/plugins/data/README.md index 0fa304c988935..da0b71122fd9e 100644 --- a/src/plugins/data/README.md +++ b/src/plugins/data/README.md @@ -6,4 +6,4 @@ - `filter` - `index_patterns` - `query` -- `search` \ No newline at end of file +- `search`: Elasticsearch API service and strategies \ No newline at end of file From 05a0625048681fb541a3839ef7229e50cc2bd80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Thu, 19 Mar 2020 10:40:22 -0400 Subject: [PATCH 02/13] Clear changes when canceling an edit to an alert (#60518) * Clear alerting edit flyout after canceling an edit * Add functional test * Fix merge conflicts --- .../alerts_list/components/alerts_list.tsx | 2 +- .../apps/triggers_actions_ui/alerts.ts | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index c409dead7c850..4bcfef78abd71 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -443,7 +443,7 @@ export const AlertsList: React.FunctionComponent = () => { addFlyoutVisible={alertFlyoutVisible} setAddFlyoutVisibility={setAlertFlyoutVisibility} /> - {editedAlertItem ? ( + {editFlyoutVisible && editedAlertItem ? ( { ]); }); + it('should reset alert when canceling an edit', async () => { + const createdAlert = await createAlert({ + alertTypeId: '.index-threshold', + name: generateUniqueKey(), + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000, 5000], + index: ['.kibana_1'], + timeField: 'alert', + }, + }); + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const editLink = await testSubjects.findAll('alertsTableCell-editLink'); + await editLink[0].click(); + + const updatedAlertName = 'Changed Alert Name'; + const nameInputToUpdate = await testSubjects.find('alertNameInput'); + await nameInputToUpdate.click(); + await nameInputToUpdate.clearValue(); + await nameInputToUpdate.type(updatedAlertName); + + await testSubjects.click('cancelSaveEditedAlertButton'); + await find.waitForDeletedByCssSelector('[data-test-subj="cancelSaveEditedAlertButton"]'); + + const editLinkPostCancel = await testSubjects.findAll('alertsTableCell-editLink'); + await editLinkPostCancel[0].click(); + + const nameInputAfterCancel = await testSubjects.find('alertNameInput'); + const textAfterCancel = await nameInputAfterCancel.getAttribute('value'); + expect(textAfterCancel).to.eql(createdAlert.name); + }); + it('should search for tags', async () => { const createdAlert = await createAlert(); await pageObjects.common.navigateToApp('triggersActions'); From 254cf99339ecc3b7ffd8b4dbe3af68f60235b54f Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 19 Mar 2020 15:42:25 +0100 Subject: [PATCH 03/13] [Cross Cluster Replication] NP Shim (#60121) * Public in WiP state, removed all 'ui/' imports * First iteration of public shimmed and working * A whole lotta WIP server side * Server-side to using the NP router + client side changes Updated the client code to properly encode requests to the server. Did first E2E test. Route tests are probably broken, need to fix them. * Removed unused error wrapping code * Update client Jest tests * Add breadcrumbs service mock * Fix server side Jest tests * Add helper functions file for server side Jest tests * Fix API integration tests * Fixed boolean logic mistake in due to refactor in index mgmt ext. Also migrated to the a more NP friendly version of index mgmt extension. * Remove unused import * Clean up some cruft and refactor URL variable names * Fix stringification of body and fix boolean server logic * Fix mocha Folder called __tests__ with Jest tests was breaking mocha. * Refactor to Jest test * Fix types issues in jest test * Migrate to new config-schema API Co-authored-by: Elastic Machine --- .../auto_follow_pattern_add.test.js | 2 +- .../auto_follow_pattern_edit.test.js | 3 +- .../auto_follow_pattern_list.test.js | 10 +- .../follower_index_add.test.js | 3 +- .../follower_index_edit.test.js | 3 +- .../follower_indices_list.test.js | 9 - .../auto_follow_pattern_add.helpers.js | 6 +- .../auto_follow_pattern_edit.helpers.js | 6 +- .../auto_follow_pattern_list.helpers.js | 6 +- .../helpers/follower_index_add.helpers.js | 6 +- .../helpers/follower_index_edit.helpers.js | 6 +- .../helpers/follower_index_list.helpers.js | 6 +- .../helpers/home.helpers.js | 6 +- .../helpers/http_requests.js | 12 +- .../helpers/setup_environment.js | 11 +- .../__jest__/client_integration/home.test.js | 1 + .../common/constants/{app.js => app.ts} | 0 .../constants/{base_path.js => base_path.ts} | 0 .../common/constants/{index.js => index.ts} | 0 .../common/constants/{plugin.js => plugin.ts} | 0 .../constants/{settings.js => settings.ts} | 0 .../cross_cluster_replication/index.js | 20 +- .../app/services/documentation_links.js | 14 - .../cross_cluster_replication/public/index.js | 1 - .../public/index.scss | 2 +- .../public/{ => np_ready}/app/_app.scss | 0 .../public/{ => np_ready}/app/app.js | 8 +- .../auto_follow_pattern_form.test.js.snap | 0 ...to_follow_pattern_action_menu.container.ts | 0 .../auto_follow_pattern_action_menu.tsx | 0 .../auto_follow_pattern_action_menu/index.ts | 0 .../auto_follow_pattern_delete_provider.d.ts | 0 .../auto_follow_pattern_delete_provider.js | 2 +- .../components/auto_follow_pattern_form.js | 4 +- .../auto_follow_pattern_form.test.js | 0 .../auto_follow_pattern_indices_preview.js | 0 .../auto_follow_pattern_page_title.js | 4 +- .../auto_follow_pattern_request_flyout.js | 2 +- .../follower_index_form.test.js.snap | 0 .../advanced_settings_fields.js | 6 +- .../follower_index_form.js | 28 +- .../follower_index_form.test.js | 0 .../follower_index_request_flyout.js | 2 +- .../components/follower_index_form/index.js | 0 .../components/follower_index_page_title.js | 4 +- .../follower_index_pause_provider.js | 2 +- .../follower_index_resume_provider.js | 2 +- .../follower_index_unfollow_provider.js | 2 +- .../app/components/form_entry_row.js | 0 .../{ => np_ready}/app/components/index.js | 0 .../components/remote_clusters_form_field.js | 2 +- .../components/remote_clusters_provider.js | 0 .../app/components/section_error.js | 8 +- .../app/components/section_loading.js | 0 .../app/components/section_unauthorized.js | 0 .../{ => np_ready}/app/constants/api.js | 0 .../{ => np_ready}/app/constants/index.js | 0 .../{ => np_ready}/app/constants/sections.js | 0 .../{ => np_ready}/app/constants/ui_metric.js | 0 .../public/{ => np_ready}/app/index.js | 3 +- .../auto_follow_pattern_add.container.js | 0 .../auto_follow_pattern_add.js | 6 +- .../sections/auto_follow_pattern_add/index.js | 0 .../auto_follow_pattern_edit.container.js | 0 .../auto_follow_pattern_edit.js | 6 +- .../auto_follow_pattern_edit/index.js | 0 .../follower_index_add.container.js | 0 .../follower_index_add/follower_index_add.js | 6 +- .../app/sections/follower_index_add/index.js | 0 .../follower_index_edit.container.js | 0 .../follower_index_edit.js | 6 +- .../app/sections/follower_index_edit/index.js | 0 .../auto_follow_pattern_list.container.js | 0 .../auto_follow_pattern_list.js | 0 .../auto_follow_pattern_table.container.js | 0 .../auto_follow_pattern_table.js | 0 .../auto_follow_pattern_table/index.js | 0 .../detail_panel/detail_panel.container.js | 0 .../components/detail_panel/detail_panel.js | 2 +- .../components/detail_panel/index.js | 0 .../components/index.js | 0 .../home/auto_follow_pattern_list/index.js | 0 .../components/context_menu/context_menu.js | 0 .../components/context_menu/index.js | 0 .../detail_panel/detail_panel.container.js | 0 .../components/detail_panel/detail_panel.js | 2 +- .../components/detail_panel/index.js | 0 .../follower_indices_table.container.js | 0 .../follower_indices_table.js | 0 .../follower_indices_table/index.js | 0 .../follower_indices_list/components/index.js | 0 .../follower_indices_list.container.js | 0 .../follower_indices_list.js | 0 .../home/follower_indices_list/index.js | 0 .../app/sections/home/home.container.js | 0 .../{ => np_ready}/app/sections/home/home.js | 8 +- .../{ => np_ready}/app/sections/home/index.js | 0 .../{ => np_ready}/app/sections/index.js | 0 ...uto_follow_pattern_validators.test.js.snap | 0 .../public/{ => np_ready}/app/services/api.js | 111 +++--- .../app/services/auto_follow_errors.js | 0 .../app/services/auto_follow_errors.test.js | 0 .../app/services/auto_follow_pattern.js | 0 .../app/services/auto_follow_pattern.test.js | 0 .../auto_follow_pattern_validators.js | 4 +- .../auto_follow_pattern_validators.test.js | 0 .../np_ready/app/services/breadcrumbs.mock.ts | 10 + .../app/services/breadcrumbs.ts} | 24 +- .../app/services/documentation_links.ts | 22 ++ .../follower_index_default_settings.js | 2 +- .../app/services/get_remote_cluster_name.js | 0 .../app/services/input_validation.js | 2 +- .../np_ready/app/services/notifications.ts | 20 + .../app/services/query_params.js | 0 .../{ => np_ready}/app/services/routing.js | 2 +- .../app/services/track_ui_metric.js | 2 +- .../{ => np_ready}/app/services/utils.js | 0 .../{ => np_ready}/app/services/utils.test.js | 0 .../{ => np_ready}/app/store/action_types.js | 0 .../{ => np_ready}/app/store/actions/api.js | 0 .../app/store/actions/auto_follow_pattern.js | 16 +- .../{ => np_ready}/app/store/actions/ccr.js | 0 .../app/store/actions/follower_index.js | 19 +- .../{ => np_ready}/app/store/actions/index.js | 0 .../public/{ => np_ready}/app/store/index.js | 0 .../{ => np_ready}/app/store/reducers/api.js | 0 .../app/store/reducers/api.test.js | 0 .../app/store/reducers/auto_follow_pattern.js | 0 .../app/store/reducers/follower_index.js | 0 .../app/store/reducers/index.js | 0 .../app/store/reducers/stats.js | 0 .../app/store/selectors/index.js | 0 .../public/{ => np_ready}/app/store/store.js | 0 .../extend_index_management.ts} | 13 +- .../public/np_ready/index.ts | 11 + .../public/np_ready/plugin.ts | 44 +++ .../public/register_routes.js | 29 +- .../__tests__/wrap_custom_error.js | 21 -- .../__tests__/wrap_unknown_error.js | 19 - .../lib/error_wrappers/wrap_custom_error.js | 18 - .../__tests__/license_pre_routing_factory.js | 66 ---- .../license_pre_routing_factory.js | 28 -- .../client/elasticsearch_ccr.js | 0 .../cross_cluster_replication_data.ts} | 14 +- .../server/np_ready/index.ts | 11 + .../ccr_stats_serialization.test.js.snap | 0 .../call_with_request_factory.js | 0 .../lib/call_with_request_factory/index.js | 0 .../lib/ccr_stats_serialization.js | 0 .../lib/ccr_stats_serialization.test.js | 0 .../lib/check_license/check_license.js | 0 .../{ => np_ready}/lib/check_license/index.js | 0 .../__tests__/wrap_es_error.test.js} | 18 +- .../lib/error_wrappers/index.ts} | 2 - .../lib/error_wrappers/wrap_es_error.ts} | 34 +- .../lib/is_es_error.ts} | 14 +- .../__tests__/is_es_error_factory.js | 0 .../lib/is_es_error_factory/index.ts} | 0 .../is_es_error_factory.ts} | 6 +- .../license_pre_routing_factory.test.ts | 64 ++++ .../lib/license_pre_routing_factory/index.ts} | 0 .../license_pre_routing_factory.ts | 32 ++ .../lib/register_license_checker/index.js | 0 .../register_license_checker.js | 10 +- .../server/np_ready/plugin.ts | 38 ++ .../api/__jest__}/auto_follow_pattern.test.js | 141 +++---- .../api/__jest__}/follower_index.test.js | 119 +++--- .../np_ready/routes/api/__jest__/helpers.ts | 37 ++ .../routes/api/auto_follow_pattern.ts | 301 +++++++++++++++ .../server/np_ready/routes/api/ccr.ts | 112 ++++++ .../np_ready/routes/api/follower_index.ts | 345 ++++++++++++++++++ .../routes/map_to_kibana_http_error.ts | 26 ++ .../routes/register_routes.ts} | 9 +- .../server/np_ready/routes/types.ts | 13 + .../server/routes/api/auto_follow_pattern.js | 256 ------------- .../server/routes/api/ccr.js | 107 ------ .../server/routes/api/follower_index.js | 328 ----------------- .../auto_follow_pattern.js | 5 +- .../follower_indices.js | 6 +- 179 files changed, 1518 insertions(+), 1261 deletions(-) rename x-pack/legacy/plugins/cross_cluster_replication/common/constants/{app.js => app.ts} (100%) rename x-pack/legacy/plugins/cross_cluster_replication/common/constants/{base_path.js => base_path.ts} (100%) rename x-pack/legacy/plugins/cross_cluster_replication/common/constants/{index.js => index.ts} (100%) rename x-pack/legacy/plugins/cross_cluster_replication/common/constants/{plugin.js => plugin.ts} (100%) rename x-pack/legacy/plugins/cross_cluster_replication/common/constants/{settings.js => settings.ts} (100%) delete mode 100644 x-pack/legacy/plugins/cross_cluster_replication/public/app/services/documentation_links.js rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/_app.scss (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/app.js (97%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/auto_follow_pattern_action_menu/index.ts (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/auto_follow_pattern_delete_provider.d.ts (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/auto_follow_pattern_delete_provider.js (98%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/auto_follow_pattern_form.js (99%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/auto_follow_pattern_form.test.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/auto_follow_pattern_indices_preview.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/auto_follow_pattern_page_title.js (92%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/auto_follow_pattern_request_flyout.js (96%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/follower_index_form/advanced_settings_fields.js (98%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/follower_index_form/follower_index_form.js (97%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/follower_index_form/follower_index_form.test.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/follower_index_form/follower_index_request_flyout.js (96%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/follower_index_form/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/follower_index_page_title.js (92%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/follower_index_pause_provider.js (98%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/follower_index_resume_provider.js (98%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/follower_index_unfollow_provider.js (98%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/form_entry_row.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/remote_clusters_form_field.js (99%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/remote_clusters_provider.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/section_error.js (79%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/section_loading.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/components/section_unauthorized.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/constants/api.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/constants/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/constants/sections.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/constants/ui_metric.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/index.js (88%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js (91%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/auto_follow_pattern_add/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js (96%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/auto_follow_pattern_edit/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/follower_index_add/follower_index_add.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/follower_index_add/follower_index_add.js (91%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/follower_index_add/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/follower_index_edit/follower_index_edit.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/follower_index_edit/follower_index_edit.js (97%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/follower_index_edit/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js (99%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/auto_follow_pattern_list/components/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/auto_follow_pattern_list/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/components/context_menu/context_menu.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/components/context_menu/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js (99%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/components/detail_panel/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/components/follower_indices_table/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/components/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/follower_indices_list.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/follower_indices_list.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/follower_indices_list/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/home.container.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/home.js (91%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/home/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/sections/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/api.js (52%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/auto_follow_errors.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/auto_follow_errors.test.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/auto_follow_pattern.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/auto_follow_pattern.test.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/auto_follow_pattern_validators.js (97%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/auto_follow_pattern_validators.test.js (100%) create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.mock.ts rename x-pack/legacy/plugins/cross_cluster_replication/public/{app/services/breadcrumbs.js => np_ready/app/services/breadcrumbs.ts} (56%) create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/documentation_links.ts rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/follower_index_default_settings.js (89%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/get_remote_cluster_name.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/input_validation.js (97%) create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/notifications.ts rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/query_params.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/routing.js (99%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/track_ui_metric.js (92%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/utils.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/services/utils.test.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/action_types.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/actions/api.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/actions/auto_follow_pattern.js (95%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/actions/ccr.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/actions/follower_index.js (95%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/actions/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/reducers/api.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/reducers/api.test.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/reducers/auto_follow_pattern.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/reducers/follower_index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/reducers/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/reducers/stats.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/selectors/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{ => np_ready}/app/store/store.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/public/{extend_index_management/index.js => np_ready/extend_index_management.ts} (67%) create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/index.ts create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts delete mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_custom_error.js delete mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_unknown_error.js delete mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_custom_error.js delete mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js delete mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/license_pre_routing_factory.js rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/client/elasticsearch_ccr.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/{cross_cluster_replication_data.js => server/np_ready/cross_cluster_replication_data.ts} (59%) create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/index.ts rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/lib/__snapshots__/ccr_stats_serialization.test.js.snap (100%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/lib/call_with_request_factory/call_with_request_factory.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/lib/call_with_request_factory/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/lib/ccr_stats_serialization.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/lib/ccr_stats_serialization.test.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/lib/check_license/check_license.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/lib/check_license/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{lib/error_wrappers/__tests__/wrap_es_error.js => np_ready/lib/error_wrappers/__tests__/wrap_es_error.test.js} (55%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{lib/error_wrappers/index.js => np_ready/lib/error_wrappers/index.ts} (72%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{lib/error_wrappers/wrap_es_error.js => np_ready/lib/error_wrappers/wrap_es_error.ts} (66%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{lib/error_wrappers/wrap_unknown_error.js => np_ready/lib/is_es_error.ts} (50%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/lib/is_es_error_factory/__tests__/is_es_error_factory.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{lib/is_es_error_factory/index.js => np_ready/lib/is_es_error_factory/index.ts} (100%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{lib/is_es_error_factory/is_es_error_factory.js => np_ready/lib/is_es_error_factory/is_es_error_factory.ts} (76%) create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts rename x-pack/legacy/plugins/cross_cluster_replication/server/{lib/license_pre_routing_factory/index.js => np_ready/lib/license_pre_routing_factory/index.ts} (100%) create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/lib/register_license_checker/index.js (100%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{ => np_ready}/lib/register_license_checker/register_license_checker.js (66%) create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts rename x-pack/legacy/plugins/cross_cluster_replication/server/{routes/api => np_ready/routes/api/__jest__}/auto_follow_pattern.test.js (68%) rename x-pack/legacy/plugins/cross_cluster_replication/server/{routes/api => np_ready/routes/api/__jest__}/follower_index.test.js (72%) create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts rename x-pack/legacy/plugins/cross_cluster_replication/server/{routes/register_routes.js => np_ready/routes/register_routes.ts} (67%) create mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts delete mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js delete mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js delete mode 100644 x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js index 06a7c2f1ec45e..2be00e70f6f84 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './helpers'; import { indexPatterns } from '../../../../../../src/plugins/data/public'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js index 04e80deaf8276..abc3e5dc9def2 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AutoFollowPatternForm } from '../../public/app/components/auto_follow_pattern_form'; +import '../../public/np_ready/app/services/breadcrumbs.mock'; +import { AutoFollowPatternForm } from '../../public/np_ready/app/components/auto_follow_pattern_form'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; import { AUTO_FOLLOW_PATTERN_EDIT } from './helpers/constants'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js index 88d8f98b973bd..20e982856dc19 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js @@ -4,21 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './helpers'; import { getAutoFollowPatternClientMock } from '../../fixtures/auto_follow_pattern'; jest.mock('ui/new_platform'); -jest.mock('ui/chrome', () => ({ - addBasePath: () => 'api/cross_cluster_replication', - breadcrumbs: { set: () => {} }, - getUiSettingsClient: () => ({ - get: x => x, - getUpdate$: () => ({ subscribe: jest.fn() }), - }), -})); - const { setup } = pageHelpers.autoFollowPatternList; describe('', () => { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js index 8d4523ca26de2..7680be9d858a4 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; -import { RemoteClustersFormField } from '../../public/app/components'; +import { RemoteClustersFormField } from '../../public/np_ready/app/components'; import { indexPatterns } from '../../../../../../src/plugins/data/public'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js index 5e2810ae882fb..cfa37ff2e0358 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; -import { FollowerIndexForm } from '../../public/app/components/follower_index_form/follower_index_form'; +import { FollowerIndexForm } from '../../public/np_ready/app/components/follower_index_form/follower_index_form'; import { FOLLOWER_INDEX_EDIT } from './helpers/constants'; jest.mock('ui/new_platform'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js index 9fd5756a7febf..dde31d1d166f9 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js @@ -10,15 +10,6 @@ import { getFollowerIndexMock } from '../../fixtures/follower_index'; jest.mock('ui/new_platform'); -jest.mock('ui/chrome', () => ({ - addBasePath: () => 'api/cross_cluster_replication', - breadcrumbs: { set: () => {} }, - getUiSettingsClient: () => ({ - get: x => x, - getUpdate$: () => ({ subscribe: jest.fn() }), - }), -})); - const { setup } = pageHelpers.followerIndexList; describe('', () => { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js index 3eb195bac7ed1..1f64e589bc4c1 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { AutoFollowPatternAdd } from '../../../public/app/sections/auto_follow_pattern_add'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { AutoFollowPatternAdd } from '../../../public/np_ready/app/sections/auto_follow_pattern_add'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js index 94a94554b9105..2b110c6552072 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { AutoFollowPatternEdit } from '../../../public/app/sections/auto_follow_pattern_edit'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { AutoFollowPatternEdit } from '../../../public/np_ready/app/sections/auto_follow_pattern_edit'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; import { AUTO_FOLLOW_PATTERN_EDIT_NAME } from './constants'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js index c0d29e8af2549..1d3e8ad6dff83 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed, findTestSubject } from '../../../../../../test_utils'; -import { AutoFollowPatternList } from '../../../public/app/sections/home/auto_follow_pattern_list'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { AutoFollowPatternList } from '../../../public/np_ready/app/sections/home/auto_follow_pattern_list'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js index 785330049cb0c..f74baa1b2ad0a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { FollowerIndexAdd } from '../../../public/app/sections/follower_index_add'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { FollowerIndexAdd } from '../../../public/np_ready/app/sections/follower_index_add'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js index 56cbe5b47229c..47f8539bb593b 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { FollowerIndexEdit } from '../../../public/app/sections/follower_index_edit'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { FollowerIndexEdit } from '../../../public/np_ready/app/sections/follower_index_edit'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; import { FOLLOWER_INDEX_EDIT_NAME } from './constants'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js index 02b64cd7f306c..2154e11e17b1f 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed, findTestSubject } from '../../../../../../test_utils'; -import { FollowerIndicesList } from '../../../public/app/sections/home/follower_indices_list'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { FollowerIndicesList } from '../../../public/np_ready/app/sections/home/follower_indices_list'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js index db30e4fe1dbe7..664ad909ba8e7 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { CrossClusterReplicationHome } from '../../../public/app/sections/home/home'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { CrossClusterReplicationHome } from '../../../public/np_ready/app/sections/home/home'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; import { BASE_PATH } from '../../../common/constants'; const testBedConfig = { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js index 9bd88a08a5a61..e2bd54a92a1f1 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js @@ -19,7 +19,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - 'api/cross_cluster_replication/follower_indices', + '/api/cross_cluster_replication/follower_indices', mockResponse(defaultResponse, response) ); }; @@ -29,7 +29,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - 'api/cross_cluster_replication/auto_follow_patterns', + '/api/cross_cluster_replication/auto_follow_patterns', mockResponse(defaultResponse, response) ); }; @@ -39,7 +39,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'DELETE', - /api\/cross_cluster_replication\/auto_follow_patterns/, + /\/api\/cross_cluster_replication\/auto_follow_patterns/, mockResponse(defaultResponse, response) ); }; @@ -61,7 +61,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - 'api/cross_cluster_replication/stats/auto_follow', + '/api/cross_cluster_replication/stats/auto_follow', mockResponse(defaultResponse, response) ); }; @@ -87,7 +87,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - /api\/cross_cluster_replication\/auto_follow_patterns\/.+/, + /\/api\/cross_cluster_replication\/auto_follow_patterns\/.+/, mockResponse(defaultResponse, response) ); }; @@ -105,7 +105,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - /api\/cross_cluster_replication\/follower_indices\/.+/, + /\/api\/cross_cluster_replication\/follower_indices\/.+/, mockResponse(defaultResponse, response) ); }; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js index 8bd86067d8513..3562ad0df5b51 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js @@ -7,14 +7,15 @@ import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; -import { setHttpClient } from '../../../public/app/services/api'; +import { setHttpClient } from '../../../public/np_ready/app/services/api'; import { init as initHttpRequests } from './http_requests'; export const setupEnvironment = () => { - // Mock Angular $q - const $q = { defer: () => ({ resolve() {} }) }; - // axios has a $http like interface so using it to simulate $http - setHttpClient(axios.create({ adapter: axiosXhrAdapter }), $q); + // axios has a similar interface to HttpSetup, but we + // flatten out the response. + const client = axios.create({ adapter: axiosXhrAdapter }); + client.interceptors.response.use(({ data }) => data); + setHttpClient(client); const { server, httpRequestsMockHelpers } = initHttpRequests(); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js index 2afa9c44a7b1c..2c536d069ef53 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; jest.mock('ui/new_platform'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/index.js b/x-pack/legacy/plugins/cross_cluster_replication/index.js index cdb867972fcf5..aff4cc5b56738 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/index.js @@ -6,9 +6,7 @@ import { resolve } from 'path'; import { PLUGIN } from './common/constants'; -import { registerLicenseChecker } from './server/lib/register_license_checker'; -import { registerRoutes } from './server/routes/register_routes'; -import { ccrDataEnricher } from './cross_cluster_replication_data'; +import { plugin } from './server/np_ready'; export function crossClusterReplication(kibana) { return new kibana.Plugin({ @@ -47,15 +45,13 @@ export function crossClusterReplication(kibana) { ); }, init: function initCcrPlugin(server) { - registerLicenseChecker(server); - registerRoutes(server); - if ( - server.config().get('xpack.ccr.ui.enabled') && - server.newPlatform.setup.plugins.indexManagement && - server.newPlatform.setup.plugins.indexManagement.indexDataEnricher - ) { - server.newPlatform.setup.plugins.indexManagement.indexDataEnricher.add(ccrDataEnricher); - } + plugin({}).setup(server.newPlatform.setup.core, { + indexManagement: server.newPlatform.setup.plugins.indexManagement, + __LEGACY: { + server, + ccrUIEnabled: server.config().get('xpack.ccr.ui.enabled'), + }, + }); }, }); } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/documentation_links.js b/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/documentation_links.js deleted file mode 100644 index 585ca7e0f5cf1..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/documentation_links.js +++ /dev/null @@ -1,14 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; - -const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; - -export const autoFollowPatternUrl = `${esBase}/ccr-put-auto-follow-pattern.html`; -export const followerIndexUrl = `${esBase}/ccr-put-follow.html`; -export const byteUnitsUrl = `${esBase}/common-options.html#byte-units`; -export const timeUnitsUrl = `${esBase}/common-options.html#time-units`; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/index.js index 4ec268f0de7f2..e92c44da34474 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/index.js @@ -5,4 +5,3 @@ */ import './register_routes'; -import './extend_index_management'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss b/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss index 6f65dc04d4427..31317e16e3e9f 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss @@ -10,4 +10,4 @@ // ccrChart__legend--small // ccrChart__legend-isLoading -@import 'app/app'; +@import 'np_ready/app/app'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/_app.scss b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/_app.scss similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/_app.scss rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/_app.scss diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/app.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/app.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/app.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/app.js index 31626750a7f37..968646a4bd1b0 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/app.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/app.js @@ -7,7 +7,6 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { Route, Switch, Redirect, withRouter } from 'react-router-dom'; -import { fatalError } from 'ui/notify'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -21,7 +20,8 @@ import { EuiTitle, } from '@elastic/eui'; -import { BASE_PATH } from '../../common/constants'; +import { BASE_PATH } from '../../../common/constants'; +import { getFatalErrors } from './services/notifications'; import { SectionError } from './components'; import routing from './services/routing'; import { loadPermissions } from './services/api'; @@ -81,7 +81,7 @@ class AppComponent extends Component { }); } catch (error) { // Expect an error in the shape provided by Angular's $http service. - if (error && error.data) { + if (error && error.body) { return this.setState({ isFetchingPermissions: false, fetchPermissionError: error, @@ -90,7 +90,7 @@ class AppComponent extends Component { // This error isn't an HTTP error, so let the fatal error screen tell the user something // unexpected happened. - fatalError( + getFatalErrors().add( error, i18n.translate('xpack.crossClusterReplication.app.checkPermissionsFatalErrorTitle', { defaultMessage: 'Cross-Cluster Replication app', diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/index.ts rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.d.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.d.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.d.ts rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.d.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.js similarity index 98% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.js index f9c03165dcf97..7803b329e6258 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.js @@ -11,7 +11,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import { deleteAutoFollowPattern } from '../store/actions'; -import { arrify } from '../../../common/services/utils'; +import { arrify } from '../../../../common/services/utils'; class AutoFollowPatternDeleteProviderUi extends PureComponent { state = { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.js index d4e418a964c8f..5bc5d8ba6e402 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.js @@ -29,8 +29,8 @@ import { EuiTitle, } from '@elastic/eui'; -import { indices } from '../../../../../../../src/plugins/es_ui_shared/public'; -import { indexPatterns } from '../../../../../../../src/plugins/data/public'; +import { indices } from '../../../../../../../../src/plugins/es_ui_shared/public'; +import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; import routing from '../services/routing'; import { extractQueryParams } from '../services/query_params'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_indices_preview.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_indices_preview.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_page_title.js similarity index 92% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_page_title.js index 43cc0a39e6e57..9880e8c983a8e 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_page_title.js @@ -17,7 +17,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { autoFollowPatternUrl } from '../services/documentation_links'; +import { getAutoFollowPatternUrl } from '../services/documentation_links'; export const AutoFollowPatternPageTitle = ({ title }) => ( @@ -35,7 +35,7 @@ export const AutoFollowPatternPageTitle = ({ title }) => ( + + { @@ -223,18 +223,24 @@ export class FollowerIndexForm extends PureComponent { isValidatingIndexName: false, }); } catch (error) { - // Expect an error in the shape provided by Angular's $http service. - if (error && error.data) { - // All validation does is check for a name collision, so we can just let the user attempt - // to save the follower index and get an error back from the API. - return this.setState({ - isValidatingIndexName: false, - }); + if (error) { + if (error.name === 'AbortError') { + // Ignore aborted requests + return; + } + // This could be an HTTP error + if (error.body) { + // All validation does is check for a name collision, so we can just let the user attempt + // to save the follower index and get an error back from the API. + return this.setState({ + isValidatingIndexName: false, + }); + } } // This error isn't an HTTP error, so let the fatal error screen tell the user something // unexpected happened. - fatalError( + getFatalErrors().add( error, i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.indexNameValidationFatalErrorTitle', diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_form.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_form.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_request_flyout.js similarity index 96% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_request_flyout.js index cba1c104e45d9..cb02a929b16f8 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_request_flyout.js @@ -26,7 +26,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { serializeFollowerIndex } from '../../../../common/services/follower_index_serialization'; +import { serializeFollowerIndex } from '../../../../../common/services/follower_index_serialization'; export class FollowerIndexRequestFlyout extends PureComponent { static propTypes = { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_page_title.js similarity index 92% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_page_title.js index a77059b5fe084..d72038096b72a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_page_title.js @@ -17,7 +17,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { followerIndexUrl } from '../services/documentation_links'; +import { getFollowerIndexUrl } from '../services/documentation_links'; export const FollowerIndexPageTitle = ({ title }) => ( @@ -35,7 +35,7 @@ export const FollowerIndexPageTitle = ({ title }) => ( ( diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/remote_clusters_provider.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/remote_clusters_provider.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_error.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_error.js similarity index 79% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_error.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_error.js index 8aaf89b30f0e7..a2c782a0e8e58 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_error.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_error.js @@ -9,21 +9,21 @@ import { EuiCallOut, EuiSpacer } from '@elastic/eui'; export function SectionError(props) { const { title, error, ...rest } = props; - const data = error.data ? error.data : error; + const data = error.body ? error.body : error; const { error: errorString, - cause, // wrapEsError() on the server add a "cause" array + attributes, // wrapEsError() on the server add a "cause" array message, } = data; return (
{message || errorString}
- {cause && ( + {attributes && attributes.cause && (
    - {cause.map((message, i) => ( + {attributes.cause.map((message, i) => (
  • {message}
  • ))}
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_loading.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_loading.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_loading.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_loading.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_unauthorized.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_unauthorized.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/api.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/api.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/sections.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/sections.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/sections.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/sections.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/ui_metric.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/ui_metric.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/ui_metric.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/ui_metric.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/index.js similarity index 88% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/index.js index 928d37558adb7..cc81fce4eebe7 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/index.js @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { I18nContext } from 'ui/i18n'; import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; @@ -12,7 +11,7 @@ import { HashRouter } from 'react-router-dom'; import { App } from './app'; import { ccrStore } from './store'; -export const renderReact = async elem => { +export const renderReact = async (elem, I18nContext) => { render( diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js similarity index 91% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js index f55b9e4bceb0b..60a6cc79376e5 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js @@ -7,12 +7,10 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageContent } from '@elastic/eui'; -import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; +import { listBreadcrumb, addBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, @@ -29,7 +27,7 @@ export class AutoFollowPatternAdd extends PureComponent { }; componentDidMount() { - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb]); + setBreadcrumbs([listBreadcrumb, addBreadcrumb]); } componentWillUnmount() { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js similarity index 96% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index a64c9566502f1..4cd3617abd989 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -8,12 +8,10 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPageContent, EuiSpacer } from '@elastic/eui'; -import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; +import { listBreadcrumb, editBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { AutoFollowPatternForm, @@ -56,7 +54,7 @@ export class AutoFollowPatternEdit extends PureComponent { selectAutoFollowPattern(decodedId); - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb]); + setBreadcrumbs([listBreadcrumb, editBreadcrumb]); } componentDidUpdate(prevProps, prevState) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.js similarity index 91% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.js index 26b5d8d6bb880..003e27777652b 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.js @@ -7,12 +7,10 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageContent } from '@elastic/eui'; -import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; +import { setBreadcrumbs, listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; import { FollowerIndexForm, FollowerIndexPageTitle, @@ -29,7 +27,7 @@ export class FollowerIndexAdd extends PureComponent { }; componentDidMount() { - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb]); + setBreadcrumbs([listBreadcrumb, addBreadcrumb]); } componentWillUnmount() { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.js index 7dc45e88f4106..21493602c12a7 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.js @@ -8,8 +8,6 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiButtonEmpty, @@ -21,7 +19,7 @@ import { EuiSpacer, } from '@elastic/eui'; -import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; +import { setBreadcrumbs, listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { FollowerIndexForm, @@ -76,7 +74,7 @@ export class FollowerIndexEdit extends PureComponent { selectFollowerIndex(decodedId); - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb]); + setBreadcrumbs([listBreadcrumb, editBreadcrumb]); } componentDidUpdate(prevProps, prevState) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js index 7b31ffa5024b7..1a6d5e6efe35a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js @@ -7,7 +7,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import { getIndexListUri } from '../../../../../../../../../../plugins/index_management/public'; +import { getIndexListUri } from '../../../../../../../../../../../plugins/index_management/public'; import { EuiButtonEmpty, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/context_menu.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/context_menu.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js index 2ad118d28f38d..3e8cf6d3e2f78 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js @@ -31,7 +31,7 @@ import { } from '@elastic/eui'; import 'brace/theme/textmate'; -import { getIndexListUri } from '../../../../../../../../../../plugins/index_management/public'; +import { getIndexListUri } from '../../../../../../../../../../../plugins/index_management/public'; import { API_STATUS } from '../../../../../constants'; import { ContextMenu } from '../context_menu'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.js similarity index 91% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.js index f89d287540ebd..88db909612245 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.js @@ -7,13 +7,11 @@ import React, { PureComponent } from 'react'; import { Route, Switch } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; -import { BASE_PATH } from '../../../../common/constants'; -import { listBreadcrumb } from '../../services/breadcrumbs'; +import { BASE_PATH } from '../../../../../common/constants'; +import { setBreadcrumbs, listBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { AutoFollowPatternList } from './auto_follow_pattern_list'; import { FollowerIndicesList } from './follower_indices_list'; @@ -47,7 +45,7 @@ export class CrossClusterReplicationHome extends PureComponent { ]; componentDidMount() { - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb]); + setBreadcrumbs([listBreadcrumb]); } static getDerivedStateFromProps(props) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js similarity index 52% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js index 52576387444fd..b50c36aa8df9f 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/api.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js @@ -3,14 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import chrome from 'ui/chrome'; import { API_BASE_PATH, API_REMOTE_CLUSTERS_BASE_PATH, API_INDEX_MANAGEMENT_BASE_PATH, -} from '../../../common/constants'; -import { arrify } from '../../../common/services/utils'; +} from '../../../../common/constants'; +import { arrify } from '../../../../common/services/utils'; import { UIM_FOLLOWER_INDEX_CREATE, UIM_FOLLOWER_INDEX_UPDATE, @@ -33,22 +31,10 @@ import { import { trackUserRequest } from './track_ui_metric'; import { areAllSettingsDefault } from './follower_index_default_settings'; -const apiPrefix = chrome.addBasePath(API_BASE_PATH); -const apiPrefixRemoteClusters = chrome.addBasePath(API_REMOTE_CLUSTERS_BASE_PATH); -const apiPrefixIndexManagement = chrome.addBasePath(API_INDEX_MANAGEMENT_BASE_PATH); - -// This is an Angular service, which is why we use this provider pattern -// to access it within our React app. let httpClient; -// The deferred AngularJS api allows us to create a deferred promise -// to be resolved later. This allows us to cancel in-flight http Requests. -// https://docs.angularjs.org/api/ng/service/$q#the-deferred-api -let $q; - -export function setHttpClient(client, $deffered) { +export function setHttpClient(client) { httpClient = client; - $q = $deffered; } export const getHttpClient = () => { @@ -57,67 +43,65 @@ export const getHttpClient = () => { // --- -const extractData = response => response.data; - const createIdString = ids => ids.map(id => encodeURIComponent(id)).join(','); /* Auto Follow Pattern */ -export const loadAutoFollowPatterns = () => - httpClient.get(`${apiPrefix}/auto_follow_patterns`).then(extractData); +export const loadAutoFollowPatterns = () => httpClient.get(`${API_BASE_PATH}/auto_follow_patterns`); export const getAutoFollowPattern = id => - httpClient.get(`${apiPrefix}/auto_follow_patterns/${encodeURIComponent(id)}`).then(extractData); + httpClient.get(`${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`); -export const loadRemoteClusters = () => httpClient.get(apiPrefixRemoteClusters).then(extractData); +export const loadRemoteClusters = () => httpClient.get(API_REMOTE_CLUSTERS_BASE_PATH); export const createAutoFollowPattern = autoFollowPattern => { - const request = httpClient.post(`${apiPrefix}/auto_follow_patterns`, autoFollowPattern); - return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_CREATE).then(extractData); + const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns`, { + body: JSON.stringify(autoFollowPattern), + }); + return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_CREATE); }; export const updateAutoFollowPattern = (id, autoFollowPattern) => { const request = httpClient.put( - `${apiPrefix}/auto_follow_patterns/${encodeURIComponent(id)}`, - autoFollowPattern + `${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`, + { body: JSON.stringify(autoFollowPattern) } ); - return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_UPDATE).then(extractData); + return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_UPDATE); }; export const deleteAutoFollowPattern = id => { const ids = arrify(id); const idString = ids.map(_id => encodeURIComponent(_id)).join(','); - const request = httpClient.delete(`${apiPrefix}/auto_follow_patterns/${idString}`); + const request = httpClient.delete(`${API_BASE_PATH}/auto_follow_patterns/${idString}`); const uiMetric = ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_DELETE_MANY : UIM_AUTO_FOLLOW_PATTERN_DELETE; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const pauseAutoFollowPattern = id => { const ids = arrify(id); const idString = ids.map(encodeURIComponent).join(','); - const request = httpClient.post(`${apiPrefix}/auto_follow_patterns/${idString}/pause`); + const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns/${idString}/pause`); const uiMetric = ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_PAUSE_MANY : UIM_AUTO_FOLLOW_PATTERN_PAUSE; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const resumeAutoFollowPattern = id => { const ids = arrify(id); const idString = ids.map(encodeURIComponent).join(','); - const request = httpClient.post(`${apiPrefix}/auto_follow_patterns/${idString}/resume`); + const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns/${idString}/resume`); const uiMetric = ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_RESUME_MANY : UIM_AUTO_FOLLOW_PATTERN_RESUME; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; /* Follower Index */ -export const loadFollowerIndices = () => - httpClient.get(`${apiPrefix}/follower_indices`).then(extractData); +export const loadFollowerIndices = () => httpClient.get(`${API_BASE_PATH}/follower_indices`); export const getFollowerIndex = id => - httpClient.get(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`).then(extractData); + httpClient.get(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`); export const createFollowerIndex = followerIndex => { const uiMetrics = [UIM_FOLLOWER_INDEX_CREATE]; @@ -125,32 +109,34 @@ export const createFollowerIndex = followerIndex => { if (isUsingAdvancedSettings) { uiMetrics.push(UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS); } - const request = httpClient.post(`${apiPrefix}/follower_indices`, followerIndex); - return trackUserRequest(request, uiMetrics).then(extractData); + const request = httpClient.post(`${API_BASE_PATH}/follower_indices`, { + body: JSON.stringify(followerIndex), + }); + return trackUserRequest(request, uiMetrics); }; export const pauseFollowerIndex = id => { const ids = arrify(id); const idString = createIdString(ids); - const request = httpClient.put(`${apiPrefix}/follower_indices/${idString}/pause`); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/pause`); const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_PAUSE_MANY : UIM_FOLLOWER_INDEX_PAUSE; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const resumeFollowerIndex = id => { const ids = arrify(id); const idString = createIdString(ids); - const request = httpClient.put(`${apiPrefix}/follower_indices/${idString}/resume`); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/resume`); const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_RESUME_MANY : UIM_FOLLOWER_INDEX_RESUME; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const unfollowLeaderIndex = id => { const ids = arrify(id); const idString = createIdString(ids); - const request = httpClient.put(`${apiPrefix}/follower_indices/${idString}/unfollow`); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/unfollow`); const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_UNFOLLOW_MANY : UIM_FOLLOWER_INDEX_UNFOLLOW; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const updateFollowerIndex = (id, followerIndex) => { @@ -159,31 +145,28 @@ export const updateFollowerIndex = (id, followerIndex) => { if (isUsingAdvancedSettings) { uiMetrics.push(UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS); } - const request = httpClient.put( - `${apiPrefix}/follower_indices/${encodeURIComponent(id)}`, - followerIndex - ); - return trackUserRequest(request, uiMetrics).then(extractData); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`, { + body: JSON.stringify(followerIndex), + }); + return trackUserRequest(request, uiMetrics); }; /* Stats */ -export const loadAutoFollowStats = () => - httpClient.get(`${apiPrefix}/stats/auto_follow`).then(extractData); +export const loadAutoFollowStats = () => httpClient.get(`${API_BASE_PATH}/stats/auto_follow`); /* Indices */ -let canceler = null; +let abortController = null; export const loadIndices = () => { - if (canceler) { - // If there is a previous request in flight we cancel it by resolving the canceler - canceler.resolve(); + if (abortController) { + abortController.abort(); + abortController = null; } - canceler = $q.defer(); - return httpClient - .get(`${apiPrefixIndexManagement}/indices`, { timeout: canceler.promise }) - .then(response => { - canceler = null; - return extractData(response); - }); + abortController = new AbortController(); + const { signal } = abortController; + return httpClient.get(`${API_INDEX_MANAGEMENT_BASE_PATH}/indices`, { signal }).then(response => { + abortController = null; + return response; + }); }; -export const loadPermissions = () => httpClient.get(`${apiPrefix}/permissions`).then(extractData); +export const loadPermissions = () => httpClient.get(`${API_BASE_PATH}/permissions`); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.js index 5186a02383d33..1b5a39658ee46 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.js @@ -8,8 +8,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { indices } from '../../../../../../../src/plugins/es_ui_shared/public'; -import { indexPatterns } from '../../../../../../../src/plugins/data/public'; +import { indices } from '../../../../../../../../src/plugins/es_ui_shared/public'; +import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; const { indexNameBeginsWithPeriod, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.mock.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.mock.ts new file mode 100644 index 0000000000000..b7c75108d4ef0 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.mock.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./breadcrumbs', () => ({ + ...jest.requireActual('./breadcrumbs'), + setBreadcrumbs: jest.fn(), +})); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/breadcrumbs.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.ts similarity index 56% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/breadcrumbs.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.ts index f8c8cc710964a..dc64cdee07f7d 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/breadcrumbs.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.ts @@ -3,9 +3,27 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { i18n } from '@kbn/i18n'; -import { BASE_PATH } from '../../../common/constants'; +import { ChromeBreadcrumb } from 'src/core/public'; + +import { ManagementAppMountParams } from '../../../../../../../../src/plugins/management/public'; + +import { BASE_PATH } from '../../../../common/constants'; + +let setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; + +export const setBreadcrumbSetter = ({ + __LEGACY, +}: { + __LEGACY: { + chrome: any; + MANAGEMENT_BREADCRUMB: ChromeBreadcrumb; + }; +}): void => { + setBreadcrumbs = (crumbs: ChromeBreadcrumb[]) => { + __LEGACY.chrome.breadcrumbs.set([__LEGACY.MANAGEMENT_BREADCRUMB, ...crumbs]); + }; +}; export const listBreadcrumb = { text: i18n.translate('xpack.crossClusterReplication.homeBreadcrumbTitle', { @@ -25,3 +43,5 @@ export const editBreadcrumb = { defaultMessage: 'Edit', }), }; + +export { setBreadcrumbs }; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/documentation_links.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/documentation_links.ts new file mode 100644 index 0000000000000..f17926d2bee10 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/documentation_links.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; + * you may not use this file except in compliance with the Elastic License. + */ + +let esBase: string; + +export const setDocLinks = ({ + DOC_LINK_VERSION, + ELASTIC_WEBSITE_URL, +}: { + ELASTIC_WEBSITE_URL: string; + DOC_LINK_VERSION: string; +}) => { + esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; +}; + +export const getAutoFollowPatternUrl = () => `${esBase}/ccr-put-auto-follow-pattern.html`; +export const getFollowerIndexUrl = () => `${esBase}/ccr-put-follow.html`; +export const getByteUnitsUrl = () => `${esBase}/common-options.html#byte-units`; +export const getTimeUnitsUrl = () => `${esBase}/common-options.html#time-units`; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/follower_index_default_settings.js similarity index 89% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/follower_index_default_settings.js index 118a54887d404..d20fa76ef5451 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/follower_index_default_settings.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../common/constants'; +import { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../../common/constants'; export const getSettingDefault = name => { if (!FOLLOWER_INDEX_ADVANCED_SETTINGS[name]) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/get_remote_cluster_name.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/get_remote_cluster_name.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/input_validation.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/input_validation.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/input_validation.js index 981b3f5929751..64c3e8412437e 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/input_validation.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/input_validation.js @@ -6,7 +6,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { indices } from '../../../../../../../src/plugins/es_ui_shared/public'; +import { indices } from '../../../../../../../../src/plugins/es_ui_shared/public'; const isEmpty = value => { return !value || !value.trim().length; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/notifications.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/notifications.ts new file mode 100644 index 0000000000000..5e1c3e9e99437 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/notifications.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { NotificationsSetup, IToasts, FatalErrorsSetup } from 'src/core/public'; + +let _notifications: IToasts; +let _fatalErrors: FatalErrorsSetup; + +export const setNotifications = ( + notifications: NotificationsSetup, + fatalErrorsSetup: FatalErrorsSetup +) => { + _notifications = notifications.toasts; + _fatalErrors = fatalErrorsSetup; +}; + +export const getNotifications = () => _notifications; +export const getFatalErrors = () => _fatalErrors; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/query_params.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/query_params.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/query_params.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/query_params.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/routing.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/routing.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/routing.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/routing.js index 487b1068794f9..965aeaaad22ad 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/routing.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/routing.js @@ -10,7 +10,7 @@ import { createLocation } from 'history'; import { stringify } from 'query-string'; -import { APPS, BASE_PATH, BASE_PATH_REMOTE_CLUSTERS } from '../../../common/constants'; +import { APPS, BASE_PATH, BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; const isModifiedEvent = event => !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/track_ui_metric.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/track_ui_metric.js similarity index 92% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/track_ui_metric.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/track_ui_metric.js index bd618f6a59e5c..36b9c185b487d 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/track_ui_metric.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/track_ui_metric.js @@ -7,7 +7,7 @@ import { createUiStatsReporter, METRIC_TYPE, -} from '../../../../../../../src/legacy/core_plugins/ui_metric/public'; +} from '../../../../../../../../src/legacy/core_plugins/ui_metric/public'; import { UIM_APP_NAME } from '../constants'; export const trackUiMetric = createUiStatsReporter(UIM_APP_NAME); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/action_types.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/action_types.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/action_types.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/action_types.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/api.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/api.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/auto_follow_pattern.js similarity index 95% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/auto_follow_pattern.js index 439858ad98ba3..b81cd30f3977a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/auto_follow_pattern.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { toastNotifications } from 'ui/notify'; +import { getNotifications } from '../../services/notifications'; import { SECTIONS, API_STATUS } from '../../constants'; import { loadAutoFollowPatterns as loadAutoFollowPatternsRequest, @@ -75,7 +75,7 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false) } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); routing.navigate(`/auto_follow_patterns`, undefined, { pattern: encodeURIComponent(id), }); @@ -111,7 +111,7 @@ export const deleteAutoFollowPattern = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsDeleted.length) { @@ -133,7 +133,7 @@ export const deleteAutoFollowPattern = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); // If we've just deleted a pattern we were looking at, we need to close the panel. const autoFollowPatternId = getSelectedAutoFollowPatternId('detail')(getState()); @@ -173,7 +173,7 @@ export const pauseAutoFollowPattern = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsPaused.length) { @@ -195,7 +195,7 @@ export const pauseAutoFollowPattern = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } }, }); @@ -229,7 +229,7 @@ export const resumeAutoFollowPattern = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsResumed.length) { @@ -251,7 +251,7 @@ export const resumeAutoFollowPattern = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } }, }); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/ccr.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/ccr.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/ccr.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/ccr.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/follower_index.js similarity index 95% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/follower_index.js index da1c259974498..ebdee067ced75 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/follower_index.js @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { toastNotifications } from 'ui/notify'; + import routing from '../../services/routing'; +import { getNotifications } from '../../services/notifications'; import { SECTIONS, API_STATUS } from '../../constants'; import { loadFollowerIndices as loadFollowerIndicesRequest, @@ -75,7 +76,7 @@ export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); routing.navigate(`/follower_indices`, undefined, { name: encodeURIComponent(name), }); @@ -111,7 +112,7 @@ export const pauseFollowerIndex = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsPaused.length) { @@ -133,7 +134,7 @@ export const pauseFollowerIndex = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); // Refresh list dispatch(loadFollowerIndices(true)); @@ -170,7 +171,7 @@ export const resumeFollowerIndex = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsResumed.length) { @@ -192,7 +193,7 @@ export const resumeFollowerIndex = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } // Refresh list @@ -229,7 +230,7 @@ export const unfollowLeaderIndex = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsUnfollowed.length) { @@ -251,7 +252,7 @@ export const unfollowLeaderIndex = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } if (response.itemsNotOpen.length) { @@ -273,7 +274,7 @@ export const unfollowLeaderIndex = id => } ); - toastNotifications.addWarning(warningMessage); + getNotifications().addWarning(warningMessage); } // If we've just unfollowed a follower index we were looking at, we need to close the panel. diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/auto_follow_pattern.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/auto_follow_pattern.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/follower_index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/follower_index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/stats.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/stats.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/stats.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/stats.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/selectors/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/selectors/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/selectors/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/selectors/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/store.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/store.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/store.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/store.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/extend_index_management/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts similarity index 67% rename from x-pack/legacy/plugins/cross_cluster_replication/public/extend_index_management/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts index c44918c500849..01c6250383fb8 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/extend_index_management/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts @@ -3,14 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { i18n } from '@kbn/i18n'; -import { npSetup } from 'ui/new_platform'; import { get } from 'lodash'; +import { IndexMgmtSetup } from '../../../../../plugins/index_management/public'; const propertyPath = 'isFollowerIndex'; const followerBadgeExtension = { - matchIndex: index => { + matchIndex: (index: any) => { return get(index, propertyPath); }, label: i18n.translate('xpack.crossClusterReplication.indexMgmtBadge.followerLabel', { @@ -20,6 +21,8 @@ const followerBadgeExtension = { filterExpression: 'isFollowerIndex:true', }; -if (npSetup.plugins.indexManagement) { - npSetup.plugins.indexManagement.extensionsService.addBadge(followerBadgeExtension); -} +export const extendIndexManagement = (indexManagement?: IndexMgmtSetup) => { + if (indexManagement) { + indexManagement.extensionsService.addBadge(followerBadgeExtension); + } +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/index.ts new file mode 100644 index 0000000000000..11aea6b7b5de4 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; + +import { CrossClusterReplicationUIPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => new CrossClusterReplicationUIPlugin(ctx); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts new file mode 100644 index 0000000000000..f7651cbb210a7 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + ChromeBreadcrumb, + CoreSetup, + Plugin, + PluginInitializerContext, + DocLinksStart, +} from 'src/core/public'; + +import { IndexMgmtSetup } from '../../../../../plugins/index_management/public'; + +// @ts-ignore; +import { setHttpClient } from './app/services/api'; +import { setBreadcrumbSetter } from './app/services/breadcrumbs'; +import { setDocLinks } from './app/services/documentation_links'; +import { setNotifications } from './app/services/notifications'; +import { extendIndexManagement } from './extend_index_management'; + +interface PluginDependencies { + indexManagement: IndexMgmtSetup; + __LEGACY: { + chrome: any; + MANAGEMENT_BREADCRUMB: ChromeBreadcrumb; + docLinks: DocLinksStart; + }; +} + +export class CrossClusterReplicationUIPlugin implements Plugin { + // @ts-ignore + constructor(private readonly ctx: PluginInitializerContext) {} + setup({ http, notifications, fatalErrors }: CoreSetup, deps: PluginDependencies) { + setHttpClient(http); + setBreadcrumbSetter(deps); + setDocLinks(deps.__LEGACY.docLinks); + setNotifications(notifications, fatalErrors); + extendIndexManagement(deps.indexManagement); + } + + start() {} +} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js b/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js index 7b9ba07f46c18..838939f46e523 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js @@ -6,15 +6,21 @@ import { unmountComponentAtNode } from 'react-dom'; import chrome from 'ui/chrome'; -import { management } from 'ui/management'; +import { management, MANAGEMENT_BREADCRUMB } from 'ui/management'; +import { npSetup, npStart } from 'ui/new_platform'; import routes from 'ui/routes'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; import { i18n } from '@kbn/i18n'; import template from './main.html'; import { BASE_PATH } from '../common/constants'; -import { renderReact } from './app'; -import { setHttpClient } from './app/services/api'; + +import { plugin } from './np_ready'; + +/** + * TODO: When this file is deleted, use the management section for rendering + */ +import { renderReact } from './np_ready/app'; const isAvailable = xpackInfo.get('features.crossClusterReplication.isAvailable'); const isActive = xpackInfo.get('features.crossClusterReplication.isActive'); @@ -37,26 +43,31 @@ if (isLicenseOK && isCcrUiEnabled) { const CCR_REACT_ROOT = 'ccrReactRoot'; + plugin({}).setup(npSetup.core, { + ...npSetup.plugins, + __LEGACY: { + chrome, + docLinks: npStart.core.docLinks, + MANAGEMENT_BREADCRUMB, + }, + }); + const unmountReactApp = () => elem && unmountComponentAtNode(elem); routes.when(`${BASE_PATH}/:section?/:subsection?/:view?/:id?`, { template, controllerAs: 'ccr', controller: class CrossClusterReplicationController { - constructor($scope, $route, $http, $q) { + constructor($scope, $route) { // React-router's does not play well with the angular router. It will cause this controller // to re-execute without the $destroy handler being called. This means that the app will be mounted twice // creating a memory leak when leaving (only 1 app will be unmounted). // To avoid this, we unmount the React app each time we enter the controller. unmountReactApp(); - // NOTE: We depend upon Angular's $http service because it's decorated with interceptors, - // e.g. to check license status per request. - setHttpClient($http, $q); - $scope.$$postDigest(() => { elem = document.getElementById(CCR_REACT_ROOT); - renderReact(elem); + renderReact(elem, npStart.core.i18n.Context); // Angular Lifecycle const appRoute = $route.current; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_custom_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_custom_error.js deleted file mode 100644 index f9c102be7a1ff..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_custom_error.js +++ /dev/null @@ -1,21 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { wrapCustomError } from '../wrap_custom_error'; - -describe('wrap_custom_error', () => { - describe('#wrapCustomError', () => { - it('should return a Boom object', () => { - const originalError = new Error('I am an error'); - const statusCode = 404; - const wrappedError = wrapCustomError(originalError, statusCode); - - expect(wrappedError.isBoom).to.be(true); - expect(wrappedError.output.statusCode).to.equal(statusCode); - }); - }); -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_unknown_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_unknown_error.js deleted file mode 100644 index 85e0b2b3033ad..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_unknown_error.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { wrapUnknownError } from '../wrap_unknown_error'; - -describe('wrap_unknown_error', () => { - describe('#wrapUnknownError', () => { - it('should return a Boom object', () => { - const originalError = new Error('I am an error'); - const wrappedError = wrapUnknownError(originalError); - - expect(wrappedError.isBoom).to.be(true); - }); - }); -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_custom_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_custom_error.js deleted file mode 100644 index 3295113d38ee5..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_custom_error.js +++ /dev/null @@ -1,18 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; - -/** - * Wraps a custom error into a Boom error response and returns it - * - * @param err Object error - * @param statusCode Error status code - * @return Object Boom error response - */ -export function wrapCustomError(err, statusCode) { - return Boom.boomify(err, { statusCode }); -} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js deleted file mode 100644 index a73aa96209c26..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { licensePreRoutingFactory } from '../license_pre_routing_factory'; - -describe('license_pre_routing_factory', () => { - describe('#reportingFeaturePreRoutingFactory', () => { - let mockServer; - let mockLicenseCheckResults; - - beforeEach(() => { - mockServer = { - plugins: { - xpack_main: { - info: { - feature: () => ({ - getLicenseCheckResults: () => mockLicenseCheckResults, - }), - }, - }, - }, - }; - }); - - it('only instantiates one instance per server', () => { - const firstInstance = licensePreRoutingFactory(mockServer); - const secondInstance = licensePreRoutingFactory(mockServer); - - expect(firstInstance).to.be(secondInstance); - }); - - describe('isAvailable is false', () => { - beforeEach(() => { - mockLicenseCheckResults = { - isAvailable: false, - }; - }); - - it('replies with 403', () => { - const licensePreRouting = licensePreRoutingFactory(mockServer); - const response = licensePreRouting(); - expect(response).to.be.an(Error); - expect(response.isBoom).to.be(true); - expect(response.output.statusCode).to.be(403); - }); - }); - - describe('isAvailable is true', () => { - beforeEach(() => { - mockLicenseCheckResults = { - isAvailable: true, - }; - }); - - it('replies with nothing', () => { - const licensePreRouting = licensePreRoutingFactory(mockServer); - const response = licensePreRouting(); - expect(response).to.be(null); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/license_pre_routing_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/license_pre_routing_factory.js deleted file mode 100644 index 548ad7ca02104..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/license_pre_routing_factory.js +++ /dev/null @@ -1,28 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { once } from 'lodash'; -import { wrapCustomError } from '../error_wrappers'; -import { PLUGIN } from '../../../common/constants'; - -export const licensePreRoutingFactory = once(server => { - const xpackMainPlugin = server.plugins.xpack_main; - - // License checking and enable/disable logic - function licensePreRouting() { - const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN.ID).getLicenseCheckResults(); - if (!licenseCheckResults.isAvailable) { - const error = new Error(licenseCheckResults.message); - const statusCode = 403; - const wrappedError = wrapCustomError(error, statusCode); - return wrappedError; - } else { - return null; - } - } - - return licensePreRouting; -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/client/elasticsearch_ccr.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/client/elasticsearch_ccr.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/cross_cluster_replication_data.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/cross_cluster_replication_data.ts similarity index 59% rename from x-pack/legacy/plugins/cross_cluster_replication/cross_cluster_replication_data.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/cross_cluster_replication_data.ts index 2944c3e6bc2ec..ae15073b979e1 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/cross_cluster_replication_data.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/cross_cluster_replication_data.ts @@ -3,9 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { APICaller } from 'src/core/server'; +import { Index } from '../../../../../plugins/index_management/server'; -export const ccrDataEnricher = async (indicesList, callWithRequest) => { - if (!indicesList || !indicesList.length) { +export const ccrDataEnricher = async (indicesList: Index[], callWithRequest: APICaller) => { + if (!indicesList?.length) { return indicesList; } const params = { @@ -18,9 +20,11 @@ export const ccrDataEnricher = async (indicesList, callWithRequest) => { params ); return indicesList.map(index => { - const isFollowerIndex = !!followerIndices.find(followerIndex => { - return followerIndex.follower_index === index.name; - }); + const isFollowerIndex = !!followerIndices.find( + (followerIndex: { follower_index: string }) => { + return followerIndex.follower_index === index.name; + } + ); return { ...index, isFollowerIndex, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/index.ts new file mode 100644 index 0000000000000..7a38d024d99a2 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; +import { CrossClusterReplicationServerPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => + new CrossClusterReplicationServerPlugin(ctx); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/__snapshots__/ccr_stats_serialization.test.js.snap b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/__snapshots__/ccr_stats_serialization.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/__snapshots__/ccr_stats_serialization.test.js.snap rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/__snapshots__/ccr_stats_serialization.test.js.snap diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/call_with_request_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/call_with_request_factory.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/call_with_request_factory.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/call_with_request_factory.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/check_license.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/check_license.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/check_license.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/check_license.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_es_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/__tests__/wrap_es_error.test.js similarity index 55% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_es_error.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/__tests__/wrap_es_error.test.js index 8241dc4329137..11a6fd4e1d816 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_es_error.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/__tests__/wrap_es_error.test.js @@ -16,24 +16,18 @@ describe('wrap_es_error', () => { originalError.response = '{}'; }); - it('should return a Boom object', () => { + it('should return the correct object', () => { const wrappedError = wrapEsError(originalError); - expect(wrappedError.isBoom).to.be(true); + expect(wrappedError.statusCode).to.be(originalError.statusCode); + expect(wrappedError.message).to.be(originalError.message); }); - it('should return the correct Boom object', () => { - const wrappedError = wrapEsError(originalError); - - expect(wrappedError.output.statusCode).to.be(originalError.statusCode); - expect(wrappedError.output.payload.message).to.be(originalError.message); - }); - - it('should return the correct Boom object with custom message', () => { + it('should return the correct object with custom message', () => { const wrappedError = wrapEsError(originalError, { 404: 'No encontrado!' }); - expect(wrappedError.output.statusCode).to.be(originalError.statusCode); - expect(wrappedError.output.payload.message).to.be('No encontrado!'); + expect(wrappedError.statusCode).to.be(originalError.statusCode); + expect(wrappedError.message).to.be('No encontrado!'); }); }); }); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/index.ts similarity index 72% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/index.ts index f275f15637091..3756b0c74fb10 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/index.ts @@ -4,6 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { wrapCustomError } from './wrap_custom_error'; export { wrapEsError } from './wrap_es_error'; -export { wrapUnknownError } from './wrap_unknown_error'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_es_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/wrap_es_error.ts similarity index 66% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_es_error.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/wrap_es_error.ts index 5f4884a3f2d26..8afd5f1a018eb 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_es_error.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/wrap_es_error.ts @@ -4,16 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; - -function extractCausedByChain(causedBy = {}, accumulator = []) { - const { reason, caused_by } = causedBy; // eslint-disable-line camelcase +function extractCausedByChain( + causedBy: Record = {}, + accumulator: string[] = [] +): string[] { + const { reason, caused_by } = causedBy; // eslint-disable-line @typescript-eslint/camelcase if (reason) { accumulator.push(reason); } - // eslint-disable-next-line camelcase + // eslint-disable-next-line @typescript-eslint/camelcase if (caused_by) { return extractCausedByChain(caused_by, accumulator); } @@ -26,34 +27,39 @@ function extractCausedByChain(causedBy = {}, accumulator = []) { * * @param err Object Error thrown by ES JS client * @param statusCodeToMessageMap Object Optional map of HTTP status codes => error messages - * @return Object Boom error response */ -export function wrapEsError(err, statusCodeToMessageMap = {}) { +export function wrapEsError( + err: any, + statusCodeToMessageMap: Record = {} +): { message: string; body?: { cause?: string[] }; statusCode: number } { const { statusCode, response } = err; const { error: { - root_cause = [], // eslint-disable-line camelcase - caused_by, // eslint-disable-line camelcase + root_cause = [], // eslint-disable-line @typescript-eslint/camelcase + caused_by = undefined, // eslint-disable-line @typescript-eslint/camelcase } = {}, } = JSON.parse(response); // If no custom message if specified for the error's status code, just // wrap the error as a Boom error response and return it if (!statusCodeToMessageMap[statusCode]) { - const boomError = Boom.boomify(err, { statusCode }); - // The caused_by chain has the most information so use that if it's available. If not then // settle for the root_cause. const causedByChain = extractCausedByChain(caused_by); const defaultCause = root_cause.length ? extractCausedByChain(root_cause[0]) : undefined; - boomError.output.payload.cause = causedByChain.length ? causedByChain : defaultCause; - return boomError; + return { + message: err.message, + statusCode, + body: { + cause: causedByChain.length ? causedByChain : defaultCause, + }, + }; } // Otherwise, use the custom message to create a Boom error response and // return it const message = statusCodeToMessageMap[statusCode]; - return new Boom(message, { statusCode }); + return { message, statusCode }; } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_unknown_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts similarity index 50% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_unknown_error.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts index ffd915c513362..4137293cf39c0 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_unknown_error.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts @@ -4,14 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; +import * as legacyElasticsearch from 'elasticsearch'; -/** - * Wraps an unknown error into a Boom error response and returns it - * - * @param err Object Unknown error - * @return Object Boom error response - */ -export function wrapUnknownError(err) { - return Boom.boomify(err); +const esErrorsParent = legacyElasticsearch.errors._Abstract; + +export function isEsError(err: Error) { + return err instanceof esErrorsParent; } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/__tests__/is_es_error_factory.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/__tests__/is_es_error_factory.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts similarity index 76% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts index 6c17554385ef8..fc6405b8e7513 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts @@ -6,13 +6,13 @@ import { memoize } from 'lodash'; -const esErrorsFactory = memoize(server => { +const esErrorsFactory = memoize((server: any) => { return server.plugins.elasticsearch.getCluster('admin').errors; }); -export function isEsErrorFactory(server) { +export function isEsErrorFactory(server: any) { const esErrors = esErrorsFactory(server); - return function isEsError(err) { + return function isEsError(err: any) { return err instanceof esErrors._Abstract; }; } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts new file mode 100644 index 0000000000000..d22505f0e315a --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { kibanaResponseFactory } from '../../../../../../../../../src/core/server'; +import { licensePreRoutingFactory } from '../license_pre_routing_factory'; + +describe('license_pre_routing_factory', () => { + describe('#reportingFeaturePreRoutingFactory', () => { + let mockDeps: any; + let mockLicenseCheckResults: any; + + const anyContext: any = {}; + const anyRequest: any = {}; + + beforeEach(() => { + mockDeps = { + __LEGACY: { + server: { + plugins: { + xpack_main: { + info: { + feature: () => ({ + getLicenseCheckResults: () => mockLicenseCheckResults, + }), + }, + }, + }, + }, + }, + requestHandler: jest.fn(), + }; + }); + + describe('isAvailable is false', () => { + beforeEach(() => { + mockLicenseCheckResults = { + isAvailable: false, + }; + }); + + it('replies with 403', async () => { + const licensePreRouting = licensePreRoutingFactory(mockDeps); + const response = await licensePreRouting(anyContext, anyRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + }); + }); + + describe('isAvailable is true', () => { + beforeEach(() => { + mockLicenseCheckResults = { + isAvailable: true, + }; + }); + + it('it calls the wrapped handler', async () => { + const licensePreRouting = licensePreRoutingFactory(mockDeps); + await licensePreRouting(anyContext, anyRequest, kibanaResponseFactory); + expect(mockDeps.requestHandler).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts new file mode 100644 index 0000000000000..c47faa940a650 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandler } from 'src/core/server'; +import { PLUGIN } from '../../../../common/constants'; + +export const licensePreRoutingFactory = ({ + __LEGACY, + requestHandler, +}: { + __LEGACY: { server: any }; + requestHandler: RequestHandler; +}) => { + const xpackMainPlugin = __LEGACY.server.plugins.xpack_main; + + // License checking and enable/disable logic + const licensePreRouting: RequestHandler = (ctx, request, response) => { + const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN.ID).getLicenseCheckResults(); + if (!licenseCheckResults.isAvailable) { + return response.forbidden({ + body: licenseCheckResults.message, + }); + } else { + return requestHandler(ctx, request, response); + } + }; + + return licensePreRouting; +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js similarity index 66% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js index dbd99efd95573..b9bb34a80ce79 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mirrorPluginStatus } from '../../../../../server/lib/mirror_plugin_status'; -import { PLUGIN } from '../../../common/constants'; +import { mirrorPluginStatus } from '../../../../../../server/lib/mirror_plugin_status'; +import { PLUGIN } from '../../../../common/constants'; import { checkLicense } from '../check_license'; -export function registerLicenseChecker(server) { - const xpackMainPlugin = server.plugins.xpack_main; - const ccrPluggin = server.plugins[PLUGIN.ID]; +export function registerLicenseChecker(__LEGACY) { + const xpackMainPlugin = __LEGACY.server.plugins.xpack_main; + const ccrPluggin = __LEGACY.server.plugins[PLUGIN.ID]; mirrorPluginStatus(xpackMainPlugin, ccrPluggin); xpackMainPlugin.status.once('green', () => { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts new file mode 100644 index 0000000000000..1012c07af3d2a --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, PluginInitializerContext, CoreSetup } from 'src/core/server'; + +import { IndexMgmtSetup } from '../../../../../plugins/index_management/server'; + +// @ts-ignore +import { registerLicenseChecker } from './lib/register_license_checker'; +// @ts-ignore +import { registerRoutes } from './routes/register_routes'; +import { ccrDataEnricher } from './cross_cluster_replication_data'; + +interface PluginDependencies { + indexManagement: IndexMgmtSetup; + __LEGACY: { + server: any; + ccrUIEnabled: boolean; + }; +} + +export class CrossClusterReplicationServerPlugin implements Plugin { + // @ts-ignore + constructor(private readonly ctx: PluginInitializerContext) {} + setup({ http }: CoreSetup, { indexManagement, __LEGACY }: PluginDependencies) { + registerLicenseChecker(__LEGACY); + + const router = http.createRouter(); + registerRoutes({ router, __LEGACY }); + if (__LEGACY.ccrUIEnabled && indexManagement && indexManagement.indexDataEnricher) { + indexManagement.indexDataEnricher.add(ccrDataEnricher); + } + } + start() {} +} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js similarity index 68% rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js index c610039cfd2ac..f3024515c7213 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js @@ -3,23 +3,23 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { deserializeAutoFollowPattern } from '../../../../../common/services/auto_follow_pattern_serialization'; +import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { getAutoFollowPatternMock, getAutoFollowPatternListMock } from '../../../../../fixtures'; +import { registerAutoFollowPatternRoutes } from '../auto_follow_pattern'; -import { deserializeAutoFollowPattern } from '../../../common/services/auto_follow_pattern_serialization'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { getAutoFollowPatternMock, getAutoFollowPatternListMock } from '../../../fixtures'; -import { registerAutoFollowPatternRoutes } from './auto_follow_pattern'; +import { createRouter, callRoute } from './helpers'; -jest.mock('../../lib/call_with_request_factory'); -jest.mock('../../lib/is_es_error_factory'); -jest.mock('../../lib/license_pre_routing_factory'); +jest.mock('../../../lib/call_with_request_factory'); +jest.mock('../../../lib/is_es_error_factory'); +jest.mock('../../../lib/license_pre_routing_factory', () => ({ + licensePreRoutingFactory: ({ requestHandler }) => requestHandler, +})); const DESERIALIZED_KEYS = Object.keys(deserializeAutoFollowPattern(getAutoFollowPatternMock())); -/** - * Hashtable to save the route handlers - */ -const routeHandlers = {}; +let routeRegistry; /** * Helper to extract all the different server route handler so we can easily call them in our tests. @@ -28,8 +28,6 @@ const routeHandlers = {}; * if a "server.route()" call is moved or deleted, then the HANDLER_INDEX_TO_ACTION must be updated here. */ const registerHandlers = () => { - let index = 0; - const HANDLER_INDEX_TO_ACTION = { 0: 'list', 1: 'create', @@ -40,15 +38,12 @@ const registerHandlers = () => { 6: 'resume', }; - const server = { - route({ handler }) { - // Save handler and increment index - routeHandlers[HANDLER_INDEX_TO_ACTION[index]] = handler; - index++; - }, - }; + routeRegistry = createRouter(HANDLER_INDEX_TO_ACTION); - registerAutoFollowPatternRoutes(server); + registerAutoFollowPatternRoutes({ + __LEGACY: {}, + router: routeRegistry.router, + }); }; /** @@ -94,14 +89,16 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('list()', () => { beforeEach(() => { - routeHandler = routeHandlers.list; + routeHandler = routeRegistry.getRoutes().list; }); it('should deserialize the response from Elasticsearch', async () => { const totalResult = 2; setHttpRequestResponse(null, getAutoFollowPatternListMock(totalResult)); - const response = await routeHandler(); + const { + options: { body: response }, + } = await callRoute(routeHandler); const autoFollowPattern = response.patterns[0]; expect(response.patterns.length).toEqual(totalResult); @@ -112,21 +109,25 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('create()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.create; + routeHandler = routeRegistry.getRoutes().create; }); it('should throw a 409 conflict error if id already exists', async () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ - payload: { - id: 'some-id', - foo: 'bar', - }, - }).catch(err => err); // return the error - - expect(response.output.statusCode).toEqual(409); + const response = await callRoute( + routeHandler, + {}, + { + body: { + id: 'some-id', + foo: 'bar', + }, + } + ); + + expect(response.status).toEqual(409); }); it('should return 200 status when the id does not exist', async () => { @@ -135,12 +136,18 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(error); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ - payload: { - id: 'some-id', - foo: 'bar', - }, - }); + const { + options: { body: response }, + } = await callRoute( + routeHandler, + {}, + { + body: { + id: 'some-id', + foo: 'bar', + }, + } + ); expect(response).toEqual({ acknowledge: true }); }); @@ -148,7 +155,7 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('update()', () => { beforeEach(() => { - routeHandler = routeHandlers.update; + routeHandler = routeRegistry.getRoutes().update; }); it('should serialize the payload before sending it to Elasticsearch', async () => { @@ -156,16 +163,16 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { const request = { params: { id: 'foo' }, - payload: { + body: { remoteCluster: 'bar1', leaderIndexPatterns: ['bar2'], followIndexPattern: 'bar3', }, }; - const response = await routeHandler(request); + const response = await callRoute(routeHandler, {}, request); - expect(response).toEqual({ + expect(response.options.body).toEqual({ id: 'foo', body: { remote_cluster: 'bar1', @@ -178,7 +185,7 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('get()', () => { beforeEach(() => { - routeHandler = routeHandlers.get; + routeHandler = routeRegistry.getRoutes().get; }); it('should return a single resource even though ES return an array with 1 item', async () => { @@ -187,21 +194,23 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, esResponse); - const response = await routeHandler({ params: { id: 1 } }); - expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS); + const response = await callRoute(routeHandler, {}, { params: { id: 1 } }); + expect(Object.keys(response.options.body)).toEqual(DESERIALIZED_KEYS); }); }); describe('delete()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.delete; + routeHandler = routeRegistry.getRoutes().delete; }); it('should delete a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a' } }); expect(response.itemsDeleted).toEqual(['a']); expect(response.errors).toEqual([]); @@ -212,9 +221,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a,b,c' } }); + const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } }); - expect(response.itemsDeleted).toEqual(['a', 'b', 'c']); + expect(response.options.body.itemsDeleted).toEqual(['a', 'b', 'c']); }); it('should catch error and return them in array', async () => { @@ -224,7 +233,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: 'a,b' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } }); expect(response.itemsDeleted).toEqual(['a']); expect(response.errors[0].id).toEqual('b'); @@ -234,13 +245,15 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('pause()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.pause; + routeHandler = routeRegistry.getRoutes().pause; }); it('accept a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a' } }); expect(response.itemsPaused).toEqual(['a']); expect(response.errors).toEqual([]); @@ -251,9 +264,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a,b,c' } }); + const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } }); - expect(response.itemsPaused).toEqual(['a', 'b', 'c']); + expect(response.options.body.itemsPaused).toEqual(['a', 'b', 'c']); }); it('should catch error and return them in array', async () => { @@ -263,7 +276,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: 'a,b' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } }); expect(response.itemsPaused).toEqual(['a']); expect(response.errors[0].id).toEqual('b'); @@ -273,13 +288,15 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('resume()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.resume; + routeHandler = routeRegistry.getRoutes().resume; }); it('accept a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a' } }); expect(response.itemsResumed).toEqual(['a']); expect(response.errors).toEqual([]); @@ -290,9 +307,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a,b,c' } }); + const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } }); - expect(response.itemsResumed).toEqual(['a', 'b', 'c']); + expect(response.options.body.itemsResumed).toEqual(['a', 'b', 'c']); }); it('should catch error and return them in array', async () => { @@ -302,7 +319,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: 'a,b' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } }); expect(response.itemsResumed).toEqual(['a']); expect(response.errors[0].id).toEqual('b'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js similarity index 72% rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js index 7e363c2758a4c..f0139e5bd7011 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js @@ -3,21 +3,23 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { deserializeFollowerIndex } from '../../../common/services/follower_index_serialization'; +import { deserializeFollowerIndex } from '../../../../../common/services/follower_index_serialization'; import { getFollowerIndexStatsMock, getFollowerIndexListStatsMock, getFollowerIndexInfoMock, getFollowerIndexListInfoMock, -} from '../../../fixtures'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { registerFollowerIndexRoutes } from './follower_index'; - -jest.mock('../../lib/call_with_request_factory'); -jest.mock('../../lib/is_es_error_factory'); -jest.mock('../../lib/license_pre_routing_factory'); +} from '../../../../../fixtures'; +import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { registerFollowerIndexRoutes } from '../follower_index'; +import { createRouter, callRoute } from './helpers'; + +jest.mock('../../../lib/call_with_request_factory'); +jest.mock('../../../lib/is_es_error_factory'); +jest.mock('../../../lib/license_pre_routing_factory', () => ({ + licensePreRoutingFactory: ({ requestHandler }) => requestHandler, +})); const DESERIALIZED_KEYS = Object.keys( deserializeFollowerIndex({ @@ -26,10 +28,7 @@ const DESERIALIZED_KEYS = Object.keys( }) ); -/** - * Hashtable to save the route handlers - */ -const routeHandlers = {}; +let routeRegistry; /** * Helper to extract all the different server route handler so we can easily call them in our tests. @@ -38,8 +37,6 @@ const routeHandlers = {}; * if a 'server.route()' call is moved or deleted, then the HANDLER_INDEX_TO_ACTION must be updated here. */ const registerHandlers = () => { - let index = 0; - const HANDLER_INDEX_TO_ACTION = { 0: 'list', 1: 'get', @@ -50,15 +47,11 @@ const registerHandlers = () => { 6: 'unfollow', }; - const server = { - route({ handler }) { - // Save handler and increment index - routeHandlers[HANDLER_INDEX_TO_ACTION[index]] = handler; - index++; - }, - }; - - registerFollowerIndexRoutes(server); + routeRegistry = createRouter(HANDLER_INDEX_TO_ACTION); + registerFollowerIndexRoutes({ + __LEGACY: {}, + router: routeRegistry.router, + }); }; /** @@ -104,7 +97,7 @@ describe('[CCR API Routes] Follower Index', () => { describe('list()', () => { beforeEach(() => { - routeHandler = routeHandlers.list; + routeHandler = routeRegistry.getRoutes().list; }); it('deserializes the response from Elasticsearch', async () => { @@ -117,7 +110,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, infoResult); setHttpRequestResponse(null, statsResult); - const response = await routeHandler(); + const { + options: { body: response }, + } = await callRoute(routeHandler); const followerIndex = response.indices[0]; expect(response.indices.length).toEqual(totalResult); @@ -127,7 +122,7 @@ describe('[CCR API Routes] Follower Index', () => { describe('get()', () => { beforeEach(() => { - routeHandler = routeHandlers.get; + routeHandler = routeRegistry.getRoutes().get; }); it('should return a single resource even though ES return an array with 1 item', async () => { @@ -138,7 +133,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { follower_indices: [followerIndexInfo] }); setHttpRequestResponse(null, { indices: [followerIndexStats] }); - const response = await routeHandler({ params: { id: mockId } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: mockId } }); expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS); }); }); @@ -146,34 +143,40 @@ describe('[CCR API Routes] Follower Index', () => { describe('create()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.create; + routeHandler = routeRegistry.getRoutes().create; }); it('should return 200 status when follower index is created', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ - payload: { - name: 'follower_index', - remoteCluster: 'remote_cluster', - leaderIndex: 'leader_index', - }, - }); + const response = await callRoute( + routeHandler, + {}, + { + body: { + name: 'follower_index', + remoteCluster: 'remote_cluster', + leaderIndex: 'leader_index', + }, + } + ); - expect(response).toEqual({ acknowledge: true }); + expect(response.options.body).toEqual({ acknowledge: true }); }); }); describe('pause()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.pause; + routeHandler = routeRegistry.getRoutes().pause; }); it('should pause a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1' } }); expect(response.itemsPaused).toEqual(['1']); expect(response.errors).toEqual([]); @@ -184,9 +187,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1,2,3' } }); + const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } }); - expect(response.itemsPaused).toEqual(['1', '2', '3']); + expect(response.options.body.itemsPaused).toEqual(['1', '2', '3']); }); it('should catch error and return them in array', async () => { @@ -196,7 +199,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: '1,2' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1,2' } }); expect(response.itemsPaused).toEqual(['1']); expect(response.errors[0].id).toEqual('2'); @@ -206,13 +211,15 @@ describe('[CCR API Routes] Follower Index', () => { describe('resume()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.resume; + routeHandler = routeRegistry.getRoutes().resume; }); it('should resume a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1' } }); expect(response.itemsResumed).toEqual(['1']); expect(response.errors).toEqual([]); @@ -223,9 +230,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1,2,3' } }); + const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } }); - expect(response.itemsResumed).toEqual(['1', '2', '3']); + expect(response.options.body.itemsResumed).toEqual(['1', '2', '3']); }); it('should catch error and return them in array', async () => { @@ -235,7 +242,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: '1,2' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1,2' } }); expect(response.itemsResumed).toEqual(['1']); expect(response.errors[0].id).toEqual('2'); @@ -245,7 +254,7 @@ describe('[CCR API Routes] Follower Index', () => { describe('unfollow()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.unfollow; + routeHandler = routeRegistry.getRoutes().unfollow; }); it('should unfollow await single item', async () => { @@ -254,7 +263,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1' } }); expect(response.itemsUnfollowed).toEqual(['1']); expect(response.errors).toEqual([]); @@ -274,9 +285,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1,2,3' } }); + const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } }); - expect(response.itemsUnfollowed).toEqual(['1', '2', '3']); + expect(response.options.body.itemsUnfollowed).toEqual(['1', '2', '3']); }); it('should catch error and return them in array', async () => { @@ -290,7 +301,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: '1,2' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1,2' } }); expect(response.itemsUnfollowed).toEqual(['1']); expect(response.errors[0].id).toEqual('2'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts new file mode 100644 index 0000000000000..555fc0937c0ad --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory } from '../../../../../../../../../src/core/server'; + +export const callRoute = ( + route: RequestHandler, + ctx = {}, + request = {}, + response = kibanaResponseFactory +) => { + return route(ctx as any, request as any, response); +}; + +export const createRouter = (indexToActionMap: Record) => { + let index = 0; + const routeHandlers: Record> = {}; + const addHandler = (ignoreCtxForNow: any, handler: RequestHandler) => { + // Save handler and increment index + routeHandlers[indexToActionMap[index]] = handler; + index++; + }; + + return { + getRoutes: () => routeHandlers, + router: { + get: addHandler, + post: addHandler, + put: addHandler, + delete: addHandler, + }, + }; +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts new file mode 100644 index 0000000000000..d458f1ccb354b --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts @@ -0,0 +1,301 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +// @ts-ignore +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { isEsError } from '../../lib/is_es_error'; +// @ts-ignore +import { + deserializeAutoFollowPattern, + deserializeListAutoFollowPatterns, + serializeAutoFollowPattern, + // @ts-ignore +} from '../../../../common/services/auto_follow_pattern_serialization'; + +import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; +import { API_BASE_PATH } from '../../../../common/constants'; + +import { RouteDependencies } from '../types'; +import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error'; + +export const registerAutoFollowPatternRoutes = ({ router, __LEGACY }: RouteDependencies) => { + /** + * Returns a list of all auto-follow patterns + */ + router.get( + { + path: `${API_BASE_PATH}/auto_follow_patterns`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const result = await callWithRequest('ccr.autoFollowPatterns'); + return response.ok({ + body: { + patterns: deserializeListAutoFollowPatterns(result.patterns), + }, + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Create an auto-follow pattern + */ + router.post( + { + path: `${API_BASE_PATH}/auto_follow_patterns`, + validate: { + body: schema.object( + { + id: schema.string(), + }, + { unknowns: 'allow' } + ), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id, ...rest } = request.body; + const body = serializeAutoFollowPattern(rest); + + /** + * First let's make sur that an auto-follow pattern with + * the same id does not exist. + */ + try { + await callWithRequest('ccr.autoFollowPattern', { id }); + // If we get here it means that an auto-follow pattern with the same id exists + return response.conflict({ + body: `An auto-follow pattern with the name "${id}" already exists.`, + }); + } catch (err) { + if (err.statusCode !== 404) { + return mapErrorToKibanaHttpResponse(err); + } + } + + try { + return response.ok({ + body: await callWithRequest('ccr.saveAutoFollowPattern', { id, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Update an auto-follow pattern + */ + router.put( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const body = serializeAutoFollowPattern(request.body); + + try { + return response.ok({ + body: await callWithRequest('ccr.saveAutoFollowPattern', { id, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Returns a single auto-follow pattern + */ + router.get( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + + try { + const result = await callWithRequest('ccr.autoFollowPattern', { id }); + const autoFollowPattern = result.patterns[0]; + + return response.ok({ + body: deserializeAutoFollowPattern(autoFollowPattern), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Delete an auto-follow pattern + */ + router.delete( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsDeleted: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.deleteAutoFollowPattern', { id: _id }) + .then(() => itemsDeleted.push(_id)) + .catch((err: Error) => { + if (isEsError(err)) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } else { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ) + ); + + return response.ok({ + body: { + itemsDeleted, + errors, + }, + }); + }, + }) + ); + + /** + * Pause auto-follow pattern(s) + */ + router.post( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}/pause`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsPaused: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.pauseAutoFollowPattern', { id: _id }) + .then(() => itemsPaused.push(_id)) + .catch((err: Error) => { + if (isEsError(err)) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } else { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ) + ); + + return response.ok({ + body: { + itemsPaused, + errors, + }, + }); + }, + }) + ); + + /** + * Resume auto-follow pattern(s) + */ + router.post( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}/resume`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsResumed: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.resumeAutoFollowPattern', { id: _id }) + .then(() => itemsResumed.push(_id)) + .catch((err: Error) => { + if (isEsError(err)) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } else { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ) + ); + + return response.ok({ + body: { + itemsResumed, + errors, + }, + }); + }, + }) + ); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts new file mode 100644 index 0000000000000..b08b056ad2c8a --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { API_BASE_PATH } from '../../../../common/constants'; +// @ts-ignore +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +// @ts-ignore +import { deserializeAutoFollowStats } from '../../lib/ccr_stats_serialization'; +import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; + +import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error'; +import { RouteDependencies } from '../types'; + +export const registerCcrRoutes = ({ router, __LEGACY }: RouteDependencies) => { + /** + * Returns Auto-follow stats + */ + router.get( + { + path: `${API_BASE_PATH}/stats/auto_follow`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const { auto_follow_stats: autoFollowStats } = await callWithRequest('ccr.stats'); + + return response.ok({ + body: deserializeAutoFollowStats(autoFollowStats), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Returns whether the user has CCR permissions + */ + router.get( + { + path: `${API_BASE_PATH}/permissions`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const xpackMainPlugin = __LEGACY.server.plugins.xpack_main; + const xpackInfo = xpackMainPlugin && xpackMainPlugin.info; + + if (!xpackInfo) { + // xpackInfo is updated via poll, so it may not be available until polling has begun. + // In this rare situation, tell the client the service is temporarily unavailable. + return response.customError({ + statusCode: 503, + body: 'Security info unavailable', + }); + } + + const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security'); + if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) { + // If security isn't enabled or available (in the case where security is enabled but license reverted to Basic) let the user use CCR. + return response.ok({ + body: { + hasPermission: true, + missingClusterPrivileges: [], + }, + }); + } + + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const { has_all_requested: hasPermission, cluster } = await callWithRequest( + 'ccr.permissions', + { + body: { + cluster: ['manage', 'manage_ccr'], + }, + } + ); + + const missingClusterPrivileges = Object.keys(cluster).reduce( + (permissions: any, permissionName: any) => { + if (!cluster[permissionName]) { + permissions.push(permissionName); + return permissions; + } + }, + [] as any[] + ); + + return response.ok({ + body: { + hasPermission, + missingClusterPrivileges, + }, + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts new file mode 100644 index 0000000000000..3896e1c02c915 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts @@ -0,0 +1,345 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { + deserializeFollowerIndex, + deserializeListFollowerIndices, + serializeFollowerIndex, + serializeAdvancedSettings, + // @ts-ignore +} from '../../../../common/services/follower_index_serialization'; +import { API_BASE_PATH } from '../../../../common/constants'; +// @ts-ignore +import { removeEmptyFields } from '../../../../common/services/utils'; +// @ts-ignore +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; + +import { RouteDependencies } from '../types'; +import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error'; + +export const registerFollowerIndexRoutes = ({ router, __LEGACY }: RouteDependencies) => { + /** + * Returns a list of all follower indices + */ + router.get( + { + path: `${API_BASE_PATH}/follower_indices`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { + id: '_all', + }); + + const { + follow_stats: { indices: followerIndicesStats }, + } = await callWithRequest('ccr.stats'); + + const followerIndicesStatsMap = followerIndicesStats.reduce((map: any, stats: any) => { + map[stats.index] = stats; + return map; + }, {}); + + const collatedFollowerIndices = followerIndices.map((followerIndex: any) => { + return { + ...followerIndex, + ...followerIndicesStatsMap[followerIndex.follower_index], + }; + }); + + return response.ok({ + body: { + indices: deserializeListFollowerIndices(collatedFollowerIndices), + }, + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Returns a single follower index pattern + */ + router.get( + { + path: `${API_BASE_PATH}/follower_indices/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + + try { + const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); + + const followerIndexInfo = followerIndices && followerIndices[0]; + + if (!followerIndexInfo) { + return response.notFound({ + body: `The follower index "${id}" does not exist.`, + }); + } + + // If this follower is paused, skip call to ES stats api since it will return 404 + if (followerIndexInfo.status === 'paused') { + return response.ok({ + body: deserializeFollowerIndex({ + ...followerIndexInfo, + }), + }); + } else { + const { + indices: followerIndicesStats, + } = await callWithRequest('ccr.followerIndexStats', { id }); + + return response.ok({ + body: deserializeFollowerIndex({ + ...followerIndexInfo, + ...(followerIndicesStats ? followerIndicesStats[0] : {}), + }), + }); + } + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Create a follower index + */ + router.post( + { + path: `${API_BASE_PATH}/follower_indices`, + validate: { + body: schema.object( + { + name: schema.string(), + }, + { unknowns: 'allow' } + ), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { name, ...rest } = request.body; + const body = removeEmptyFields(serializeFollowerIndex(rest)); + + try { + return response.ok({ + body: await callWithRequest('ccr.saveFollowerIndex', { name, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Edit a follower index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + + // We need to first pause the follower and then resume it passing the advanced settings + try { + const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); + const followerIndexInfo = followerIndices && followerIndices[0]; + if (!followerIndexInfo) { + return response.notFound({ body: `The follower index "${id}" does not exist.` }); + } + + // Retrieve paused state instead of pulling it from the payload to ensure it's not stale. + const isPaused = followerIndexInfo.status === 'paused'; + // Pause follower if not already paused + if (!isPaused) { + await callWithRequest('ccr.pauseFollowerIndex', { id }); + } + + // Resume follower + const body = removeEmptyFields(serializeAdvancedSettings(request.body)); + return response.ok({ + body: await callWithRequest('ccr.resumeFollowerIndex', { id, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Pauses a follower index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}/pause`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsPaused: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.pauseFollowerIndex', { id: _id }) + .then(() => itemsPaused.push(_id)) + .catch((err: Error) => { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + }) + ) + ); + + return response.ok({ + body: { + itemsPaused, + errors, + }, + }); + }, + }) + ); + + /** + * Resumes a follower index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}/resume`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsResumed: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.resumeFollowerIndex', { id: _id }) + .then(() => itemsResumed.push(_id)) + .catch((err: Error) => { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + }) + ) + ); + + return response.ok({ + body: { + itemsResumed, + errors, + }, + }); + }, + }) + ); + + /** + * Unfollow follower index's leader index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}/unfollow`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsUnfollowed: string[] = []; + const itemsNotOpen: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(async _id => { + try { + // Try to pause follower, let it fail silently since it may already be paused + try { + await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); + } catch (e) { + // Swallow errors + } + + // Close index + await callWithRequest('indices.close', { index: _id }); + + // Unfollow leader + await callWithRequest('ccr.unfollowLeaderIndex', { id: _id }); + + // Try to re-open the index, store failures in a separate array to surface warnings in the UI + // This will allow users to query their index normally after unfollowing + try { + await callWithRequest('indices.open', { index: _id }); + } catch (e) { + itemsNotOpen.push(_id); + } + + // Push success + itemsUnfollowed.push(_id); + } catch (err) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ); + + return response.ok({ + body: { + itemsUnfollowed, + itemsNotOpen, + errors, + }, + }); + }, + }) + ); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts new file mode 100644 index 0000000000000..6a81bd26dc47d --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { kibanaResponseFactory } from '../../../../../../../src/core/server'; +// @ts-ignore +import { wrapEsError } from '../lib/error_wrappers'; +import { isEsError } from '../lib/is_es_error'; + +export const mapErrorToKibanaHttpResponse = (err: any) => { + if (isEsError(err)) { + const { statusCode, message, body } = wrapEsError(err); + return kibanaResponseFactory.customError({ + statusCode, + body: { + message, + attributes: { + cause: body?.cause, + }, + }, + }); + } + return kibanaResponseFactory.internalError(err); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts similarity index 67% rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts index 6e4088ec8600f..7e59417550691 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts @@ -7,9 +7,10 @@ import { registerAutoFollowPatternRoutes } from './api/auto_follow_pattern'; import { registerFollowerIndexRoutes } from './api/follower_index'; import { registerCcrRoutes } from './api/ccr'; +import { RouteDependencies } from './types'; -export function registerRoutes(server) { - registerAutoFollowPatternRoutes(server); - registerFollowerIndexRoutes(server); - registerCcrRoutes(server); +export function registerRoutes(deps: RouteDependencies) { + registerAutoFollowPatternRoutes(deps); + registerFollowerIndexRoutes(deps); + registerCcrRoutes(deps); } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts new file mode 100644 index 0000000000000..7f57c20c536e0 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter } from 'src/core/server'; + +export interface RouteDependencies { + router: IRouter; + __LEGACY: { + server: any; + }; +} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js deleted file mode 100644 index 4667f0a110c1f..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js +++ /dev/null @@ -1,256 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import Boom from 'boom'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; -import { - deserializeAutoFollowPattern, - deserializeListAutoFollowPatterns, - serializeAutoFollowPattern, -} from '../../../common/services/auto_follow_pattern_serialization'; -import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; -import { API_BASE_PATH } from '../../../common/constants'; - -export const registerAutoFollowPatternRoutes = server => { - const isEsError = isEsErrorFactory(server); - const licensePreRouting = licensePreRoutingFactory(server); - - /** - * Returns a list of all auto-follow patterns - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const response = await callWithRequest('ccr.autoFollowPatterns'); - return { - patterns: deserializeListAutoFollowPatterns(response.patterns), - }; - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Create an auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id, ...rest } = request.payload; - const body = serializeAutoFollowPattern(rest); - - /** - * First let's make sur that an auto-follow pattern with - * the same id does not exist. - */ - try { - await callWithRequest('ccr.autoFollowPattern', { id }); - // If we get here it means that an auto-follow pattern with the same id exists - const error = Boom.conflict(`An auto-follow pattern with the name "${id}" already exists.`); - throw error; - } catch (err) { - if (err.statusCode !== 404) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - } - - try { - return await callWithRequest('ccr.saveAutoFollowPattern', { id, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Update an auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const body = serializeAutoFollowPattern(request.payload); - - try { - return await callWithRequest('ccr.saveAutoFollowPattern', { id, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Returns a single auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - - try { - const response = await callWithRequest('ccr.autoFollowPattern', { id }); - const autoFollowPattern = response.patterns[0]; - - return deserializeAutoFollowPattern(autoFollowPattern); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Delete an auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, - method: 'DELETE', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsDeleted = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.deleteAutoFollowPattern', { id: _id }) - .then(() => itemsDeleted.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsDeleted, - errors, - }; - }, - }); - - /** - * Pause auto-follow pattern(s) - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}/pause`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsPaused = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.pauseAutoFollowPattern', { id: _id }) - .then(() => itemsPaused.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsPaused, - errors, - }; - }, - }); - - /** - * Resume auto-follow pattern(s) - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}/resume`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsResumed = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.resumeAutoFollowPattern', { id: _id }) - .then(() => itemsResumed.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsResumed, - errors, - }; - }, - }); -}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js deleted file mode 100644 index 8255eb6e86b07..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js +++ /dev/null @@ -1,107 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; - -import { API_BASE_PATH } from '../../../common/constants'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; -import { deserializeAutoFollowStats } from '../../lib/ccr_stats_serialization'; -import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; - -export const registerCcrRoutes = server => { - const isEsError = isEsErrorFactory(server); - const licensePreRouting = licensePreRoutingFactory(server); - - /** - * Returns Auto-follow stats - */ - server.route({ - path: `${API_BASE_PATH}/stats/auto_follow`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const { auto_follow_stats: autoFollowStats } = await callWithRequest('ccr.stats'); - - return deserializeAutoFollowStats(autoFollowStats); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Returns whether the user has CCR permissions - */ - server.route({ - path: `${API_BASE_PATH}/permissions`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const xpackMainPlugin = server.plugins.xpack_main; - const xpackInfo = xpackMainPlugin && xpackMainPlugin.info; - - if (!xpackInfo) { - // xpackInfo is updated via poll, so it may not be available until polling has begun. - // In this rare situation, tell the client the service is temporarily unavailable. - throw new Boom('Security info unavailable', { statusCode: 503 }); - } - - const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security'); - if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) { - // If security isn't enabled or available (in the case where security is enabled but license reverted to Basic) let the user use CCR. - return { - hasPermission: true, - missingClusterPrivileges: [], - }; - } - - const callWithRequest = callWithRequestFactory(server, request); - - try { - const { has_all_requested: hasPermission, cluster } = await callWithRequest( - 'ccr.permissions', - { - body: { - cluster: ['manage', 'manage_ccr'], - }, - } - ); - - const missingClusterPrivileges = Object.keys(cluster).reduce( - (permissions, permissionName) => { - if (!cluster[permissionName]) { - permissions.push(permissionName); - return permissions; - } - }, - [] - ); - - return { - hasPermission, - missingClusterPrivileges, - }; - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); -}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js deleted file mode 100644 index e532edaa39636..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js +++ /dev/null @@ -1,328 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; - -import { - deserializeFollowerIndex, - deserializeListFollowerIndices, - serializeFollowerIndex, - serializeAdvancedSettings, -} from '../../../common/services/follower_index_serialization'; -import { API_BASE_PATH } from '../../../common/constants'; -import { removeEmptyFields } from '../../../common/services/utils'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; - -export const registerFollowerIndexRoutes = server => { - const isEsError = isEsErrorFactory(server); - const licensePreRouting = licensePreRoutingFactory(server); - - /** - * Returns a list of all follower indices - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { - id: '_all', - }); - - const { - follow_stats: { indices: followerIndicesStats }, - } = await callWithRequest('ccr.stats'); - - const followerIndicesStatsMap = followerIndicesStats.reduce((map, stats) => { - map[stats.index] = stats; - return map; - }, {}); - - const collatedFollowerIndices = followerIndices.map(followerIndex => { - return { - ...followerIndex, - ...followerIndicesStatsMap[followerIndex.follower_index], - }; - }); - - return { - indices: deserializeListFollowerIndices(collatedFollowerIndices), - }; - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Returns a single follower index pattern - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - - try { - const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); - - const followerIndexInfo = followerIndices && followerIndices[0]; - - if (!followerIndexInfo) { - const error = Boom.notFound(`The follower index "${id}" does not exist.`); - throw error; - } - - // If this follower is paused, skip call to ES stats api since it will return 404 - if (followerIndexInfo.status === 'paused') { - return deserializeFollowerIndex({ - ...followerIndexInfo, - }); - } else { - const { indices: followerIndicesStats } = await callWithRequest( - 'ccr.followerIndexStats', - { id } - ); - - return deserializeFollowerIndex({ - ...followerIndexInfo, - ...(followerIndicesStats ? followerIndicesStats[0] : {}), - }); - } - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Create a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { name, ...rest } = request.payload; - const body = removeEmptyFields(serializeFollowerIndex(rest)); - - try { - return await callWithRequest('ccr.saveFollowerIndex', { name, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Edit a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - - async function isFollowerIndexPaused() { - const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); - - const followerIndexInfo = followerIndices && followerIndices[0]; - - if (!followerIndexInfo) { - const error = Boom.notFound(`The follower index "${id}" does not exist.`); - throw error; - } - - return followerIndexInfo.status === 'paused'; - } - - // We need to first pause the follower and then resume it passing the advanced settings - try { - // Retrieve paused state instead of pulling it from the payload to ensure it's not stale. - const isPaused = await isFollowerIndexPaused(); - // Pause follower if not already paused - if (!isPaused) { - await callWithRequest('ccr.pauseFollowerIndex', { id }); - } - - // Resume follower - const body = removeEmptyFields(serializeAdvancedSettings(request.payload)); - return await callWithRequest('ccr.resumeFollowerIndex', { id, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Pauses a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}/pause`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsPaused = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.pauseFollowerIndex', { id: _id }) - .then(() => itemsPaused.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsPaused, - errors, - }; - }, - }); - - /** - * Resumes a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}/resume`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsResumed = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.resumeFollowerIndex', { id: _id }) - .then(() => itemsResumed.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsResumed, - errors, - }; - }, - }); - - /** - * Unfollow follower index's leader index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}/unfollow`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsUnfollowed = []; - const itemsNotOpen = []; - const errors = []; - - await Promise.all( - ids.map(async _id => { - try { - // Try to pause follower, let it fail silently since it may already be paused - try { - await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); - } catch (e) { - // Swallow errors - } - - // Close index - await callWithRequest('indices.close', { index: _id }); - - // Unfollow leader - await callWithRequest('ccr.unfollowLeaderIndex', { id: _id }); - - // Try to re-open the index, store failures in a separate array to surface warnings in the UI - // This will allow users to query their index normally after unfollowing - try { - await callWithRequest('indices.open', { index: _id }); - } catch (e) { - itemsNotOpen.push(_id); - } - - // Push success - itemsUnfollowed.push(_id); - } catch (err) { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - } - }) - ); - - return { - itemsUnfollowed, - itemsNotOpen, - errors, - }; - }, - }); -}; diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js index 30459be6ee1dd..3efb4d6600f7f 100644 --- a/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js @@ -41,7 +41,7 @@ export default function({ getService }) { payload.remoteCluster = 'unknown-cluster'; const { body } = await createAutoFollowPattern(undefined, payload).expect(404); - expect(body.cause[0]).to.contain('no such remote cluster'); + expect(body.attributes.cause[0]).to.contain('no such remote cluster'); }); }); @@ -52,6 +52,7 @@ export default function({ getService }) { it('should create an auto-follow pattern when cluster is known', async () => { const name = getRandomString(); const { body } = await createAutoFollowPattern(name).expect(200); + console.log(body); expect(body.acknowledged).to.eql(true); }); @@ -62,7 +63,7 @@ export default function({ getService }) { const name = getRandomString(); const { body } = await getAutoFollowPattern(name).expect(404); - expect(body.cause).not.to.be(undefined); + expect(body.attributes.cause).not.to.be(undefined); }); it('should return an auto-follow pattern that was created', async () => { diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js index a5b12668ad9b9..5f9ebbd2a0a3f 100644 --- a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js @@ -47,13 +47,13 @@ export default function({ getService }) { payload.remoteCluster = 'unknown-cluster'; const { body } = await createFollowerIndex(undefined, payload).expect(404); - expect(body.cause[0]).to.contain('no such remote cluster'); + expect(body.attributes.cause[0]).to.contain('no such remote cluster'); }); it('should throw a 404 error trying to follow an unknown index', async () => { const payload = getFollowerIndexPayload(); const { body } = await createFollowerIndex(undefined, payload).expect(404); - expect(body.cause[0]).to.contain('no such index'); + expect(body.attributes.cause[0]).to.contain('no such index'); }); it('should create a follower index that follows an existing remote index', async () => { @@ -75,7 +75,7 @@ export default function({ getService }) { const name = getRandomString(); const { body } = await getFollowerIndex(name).expect(404); - expect(body.cause[0]).to.contain('no such index'); + expect(body.attributes.cause[0]).to.contain('no such index'); }); it('should return a follower index that was created', async () => { From 73a8548d3b5b35c1c85aa8e3443a394459ea45b1 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Thu, 19 Mar 2020 14:51:17 +0000 Subject: [PATCH 04/13] Removing isEmptyState from embeddable input (#60511) --- src/plugins/dashboard/public/embeddable/dashboard_container.tsx | 1 + src/plugins/embeddable/public/lib/containers/container.ts | 1 - src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plugins/dashboard/public/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/embeddable/dashboard_container.tsx index 86a6e374d3e25..d29ce2e4f38f5 100644 --- a/src/plugins/dashboard/public/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/embeddable/dashboard_container.tsx @@ -57,6 +57,7 @@ export interface DashboardContainerInput extends ContainerInput { panels: { [panelId: string]: DashboardPanelState; }; + isEmptyState?: boolean; } interface IndexSignature { diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 5ce79537ccaf3..4ab74e1883917 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -240,7 +240,6 @@ export abstract class Container< ...this.input.panels, [panelState.explicitInput.id]: panelState, }, - isEmptyState: false, } as Partial); return await this.untilEmbeddableLoaded(panelState.explicitInput.id); diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 7fef80edde85f..6345c34b0dda2 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -29,7 +29,6 @@ export interface EmbeddableInput { id: string; lastReloadRequestTime?: number; hidePanelTitles?: boolean; - isEmptyState?: boolean; /** * Reserved key for `ui_actions` events. From b0a6b302adbfbe77b3c4fcbfa39656005d5e80a3 Mon Sep 17 00:00:00 2001 From: MadameSheema Date: Thu, 19 Mar 2020 16:32:19 +0100 Subject: [PATCH 05/13] fixes drag and drop flakiness (#60625) --- x-pack/legacy/plugins/siem/cypress/tasks/common.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/common.ts b/x-pack/legacy/plugins/siem/cypress/tasks/common.ts index a99471d92828e..03a1fe4496030 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/common.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/common.ts @@ -23,14 +23,14 @@ export const drag = (subject: JQuery) => { clientY: subjectLocation.top, force: true, }) - .wait(5) + .wait(100) .trigger('mousemove', { button: primaryButton, clientX: subjectLocation.left + dndSloppyClickDetectionThreshold, clientY: subjectLocation.top, force: true, }) - .wait(5); + .wait(100); }; /** Drags the subject being dragged on the specified drop target, but does not drop it */ @@ -44,7 +44,7 @@ export const dragWithoutDrop = (dropTarget: JQuery) => { export const drop = (dropTarget: JQuery) => { cy.wrap(dropTarget) .trigger('mousemove', { button: primaryButton, force: true }) - .wait(5) + .wait(100) .trigger('mouseup', { force: true }) - .wait(5); + .wait(100); }; From ae0e35041e7f385c96a1a677cdd1c1b93e54e5af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Thu, 19 Mar 2020 16:42:58 +0100 Subject: [PATCH 06/13] [Logs UI] Correctly update the expanded log rate table rows (#60306) This ensures that the content of the expanded rows in the log rate table reflect the most recent results. Fixes #60300 --- .../sections/anomalies/expanded_row.tsx | 1 - .../sections/anomalies/table.tsx | 118 ++++++++---------- 2 files changed, 55 insertions(+), 64 deletions(-) diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx index f8a7f12364cf9..11ea137c95a1f 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx @@ -21,7 +21,6 @@ import { AnomaliesChart } from './chart'; export const AnomaliesTableExpandedRow: React.FunctionComponent<{ partitionId: string; - topAnomalyScore: number; results: LogEntryRateResults; setTimeRange: (timeRange: TimeRange) => void; timeRange: TimeRange; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index 5cb5f3a993d48..a9090a90c0b92 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -8,7 +8,7 @@ import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo, useState } from 'react'; - +import { useSet } from 'react-use'; import { euiStyled } from '../../../../../../../observability/public'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { @@ -64,9 +64,31 @@ export const AnomaliesTable: React.FunctionComponent<{ }); }, [results]); - const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState< - Record - >({}); + const [expandedDatasetIds, { add: expandDataset, remove: collapseDataset }] = useSet( + new Set() + ); + + const expandedDatasetRowContents = useMemo( + () => + [...expandedDatasetIds].reduce>( + (aggregatedDatasetRows, datasetId) => { + return { + ...aggregatedDatasetRows, + [getFriendlyNameForPartitionId(datasetId)]: ( + + ), + }; + }, + {} + ), + [expandedDatasetIds, jobId, results, setTimeRange, timeRange] + ); const [sorting, setSorting] = useState({ sort: { @@ -98,73 +120,43 @@ export const AnomaliesTable: React.FunctionComponent<{ return sorting.sort.direction === 'asc' ? sortedItems : sortedItems.reverse(); }, [tableItems, sorting]); - const expandItem = useCallback( - (item: TableItem) => { - const newItemIdToExpandedRowMap = { - ...itemIdToExpandedRowMap, - [item.partitionName]: ( - > = useMemo( + () => [ + { + field: 'partitionName', + name: partitionColumnName, + sortable: true, + truncateText: true, + }, + { + field: 'topAnomalyScore', + name: maxAnomalyScoreColumnName, + sortable: true, + truncateText: true, + dataType: 'number' as const, + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: (item: TableItem) => ( + ), - }; - setItemIdToExpandedRowMap(newItemIdToExpandedRowMap); - }, - [itemIdToExpandedRowMap, jobId, results, setTimeRange, timeRange] + }, + ], + [collapseDataset, expandDataset, expandedDatasetIds] ); - const collapseItem = useCallback( - (item: TableItem) => { - if (itemIdToExpandedRowMap[item.partitionName]) { - const { - [item.partitionName]: toggledItem, - ...remainingExpandedRowMap - } = itemIdToExpandedRowMap; - setItemIdToExpandedRowMap(remainingExpandedRowMap); - } - }, - [itemIdToExpandedRowMap] - ); - - const columns: Array> = [ - { - field: 'partitionName', - name: partitionColumnName, - sortable: true, - truncateText: true, - }, - { - field: 'topAnomalyScore', - name: maxAnomalyScoreColumnName, - sortable: true, - truncateText: true, - dataType: 'number' as const, - }, - { - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - render: (item: TableItem) => ( - - ), - }, - ]; - return ( Date: Thu, 19 Mar 2020 16:45:40 +0100 Subject: [PATCH 07/13] [ML] Use a new ML endpoint to estimate a model memory (#60376) * [ML] refactor calculate_model_memory_limit route, use estimateModelMemory endpoint * [ML] refactor validate_model_memory_limit, migrate tests to jest * [ML] fix typing issue * [ML] start estimateModelMemory url with / * [ML] fix typo, filter mlcategory * [ML] extract getCardinalities function * [ML] fields_service.ts * [ML] wip getMaxBucketCardinality * [ML] refactor and comments * [ML] fix aggs keys with special characters, fix integration tests * [ML] use pre-defined job types * [ML] fallback to 0 in case max bucket cardinality receives null * [ML] calculateModelMemoryLimit on influencers change * [ML] fix maxModelMemoryLimit * [ML] cap aggregation to max 1000 buckets * [ML] rename intervalDuration --- .../types/anomaly_detection_jobs/job.ts | 6 +- .../job_creator/multi_metric_job_creator.ts | 7 +- .../services/ml_api_service/index.ts | 13 +- .../ml/server/client/elasticsearch_ml.ts | 8 + .../calculate_model_memory_limit.d.ts | 20 -- .../calculate_model_memory_limit.js | 117 ------- .../calculate_model_memory_limit.ts | 187 +++++++++++ .../models/fields_service/fields_service.d.ts | 21 -- .../models/fields_service/fields_service.js | 148 --------- .../models/fields_service/fields_service.ts | 296 ++++++++++++++++++ ...e_job_object.js => validate_job_object.ts} | 3 +- .../validate_model_memory_limit.js | 170 ---------- ...js => validate_model_memory_limit.test.ts} | 162 ++++++---- .../validate_model_memory_limit.ts | 135 ++++++++ .../ml/server/routes/anomaly_detectors.ts | 2 +- .../ml/server/routes/job_validation.ts | 18 +- .../schemas/anomaly_detectors_schema.ts | 16 +- .../routes/schemas/job_validation_schema.ts | 6 +- .../apis/ml/calculate_model_memory_limit.ts | 118 +++---- 19 files changed, 819 insertions(+), 634 deletions(-) delete mode 100644 x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.d.ts delete mode 100644 x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.js create mode 100644 x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts delete mode 100644 x-pack/plugins/ml/server/models/fields_service/fields_service.d.ts delete mode 100644 x-pack/plugins/ml/server/models/fields_service/fields_service.js create mode 100644 x-pack/plugins/ml/server/models/fields_service/fields_service.ts rename x-pack/plugins/ml/server/models/job_validation/{validate_job_object.js => validate_job_object.ts} (96%) delete mode 100644 x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.js rename x-pack/plugins/ml/server/models/job_validation/{__tests__/validate_model_memory_limit.js => validate_model_memory_limit.test.ts} (60%) create mode 100644 x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts index 823d27e4617b2..bf8e3031db975 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts @@ -81,7 +81,7 @@ export interface ModelPlotConfig { // TODO, finish this when it's needed export interface CustomRule { - actions: any; - scope: object; - conditions: object; + actions: string[]; + scope?: object; + conditions: any[]; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts index 31155b0a96ed4..f115c203624eb 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts @@ -88,16 +88,13 @@ export class MultiMetricJobCreator extends JobCreator { // called externally to set the model memory limit based current detector configuration public async calculateModelMemoryLimit() { - if (this._splitField === null) { - // not split field, use the default + if (this.jobConfig.analysis_config.detectors.length === 0) { this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; } else { const { modelMemoryLimit } = await ml.calculateModelMemoryLimit({ + analysisConfig: this.jobConfig.analysis_config, indexPattern: this._indexPatternTitle, - splitFieldName: this._splitField.name, query: this._datafeed_config.query, - fieldNames: this.fields.map(f => f.id), - influencerNames: this._influencers, timeFieldName: this._job_config.data_description.time_field, earliestMs: this._start, latestMs: this._end, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index b8e21898a4bb3..cd4a97bd10ed4 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -22,6 +22,7 @@ import { Datafeed, CombinedJob, Detector, + AnalysisConfig, } from '../../../../common/types/anomaly_detection_jobs'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { FieldRequestConfig } from '../../datavisualizer/index_based/common'; @@ -532,30 +533,24 @@ export const ml = { }, calculateModelMemoryLimit({ + analysisConfig, indexPattern, - splitFieldName, query, - fieldNames, - influencerNames, timeFieldName, earliestMs, latestMs, }: { + analysisConfig: AnalysisConfig; indexPattern: string; - splitFieldName: string; query: any; - fieldNames: string[]; - influencerNames: string[]; timeFieldName: string; earliestMs: number; latestMs: number; }) { const body = JSON.stringify({ + analysisConfig, indexPattern, - splitFieldName, query, - fieldNames, - influencerNames, timeFieldName, earliestMs, latestMs, diff --git a/x-pack/plugins/ml/server/client/elasticsearch_ml.ts b/x-pack/plugins/ml/server/client/elasticsearch_ml.ts index 1d09a6c765e29..ed4dc64cde3bd 100644 --- a/x-pack/plugins/ml/server/client/elasticsearch_ml.ts +++ b/x-pack/plugins/ml/server/client/elasticsearch_ml.ts @@ -413,6 +413,14 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'POST', }); + ml.estimateModelMemory = ca({ + url: { + fmt: '/_ml/anomaly_detectors/_estimate_model_memory', + }, + needBody: true, + method: 'POST', + }); + ml.datafeedPreview = ca({ url: { fmt: '/_ml/datafeeds/<%=datafeedId%>/_preview', diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.d.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.d.ts deleted file mode 100644 index 927728040bdd7..0000000000000 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.d.ts +++ /dev/null @@ -1,20 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { APICaller } from 'kibana/server'; - -export function calculateModelMemoryLimitProvider( - callAsCurrentUser: APICaller -): ( - indexPattern: string, - splitFieldName: string, - query: any, - fieldNames: any, - influencerNames: any, // string[] ? - timeFieldName: string, - earliestMs: number, - latestMs: number -) => Promise; diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.js b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.js deleted file mode 100644 index 8a06895762dc2..0000000000000 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.js +++ /dev/null @@ -1,117 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -// calculates the size of the model memory limit used in the job config -// based on the cardinality of the field being used to split the data. -// the limit should be 10MB plus 20kB per series, rounded up to the nearest MB. -import numeral from '@elastic/numeral'; -import { fieldsServiceProvider } from '../fields_service'; - -export function calculateModelMemoryLimitProvider(callAsCurrentUser) { - const fieldsService = fieldsServiceProvider(callAsCurrentUser); - - return function calculateModelMemoryLimit( - indexPattern, - splitFieldName, - query, - fieldNames, - influencerNames, - timeFieldName, - earliestMs, - latestMs, - allowMMLGreaterThanMax = false - ) { - return new Promise((response, reject) => { - const limits = {}; - callAsCurrentUser('ml.info') - .then(resp => { - if (resp.limits !== undefined && resp.limits.max_model_memory_limit !== undefined) { - limits.max_model_memory_limit = resp.limits.max_model_memory_limit; - } - }) - .catch(error => { - reject(error); - }); - - // find the cardinality of the split field - function splitFieldCardinality() { - return fieldsService.getCardinalityOfFields( - indexPattern, - [splitFieldName], - query, - timeFieldName, - earliestMs, - latestMs - ); - } - - // find the cardinality of an influencer field - function influencerCardinality(influencerName) { - return fieldsService.getCardinalityOfFields( - indexPattern, - [influencerName], - query, - timeFieldName, - earliestMs, - latestMs - ); - } - - const calculations = [ - splitFieldCardinality(), - ...influencerNames.map(inf => influencerCardinality(inf)), - ]; - - Promise.all(calculations) - .then(responses => { - let mmlMB = 0; - const MB = 1000; - responses.forEach((resp, i) => { - let mmlKB = 0; - if (i === 0) { - // first in the list is the basic calculation. - // a base of 10MB plus 64KB per series per detector - // i.e. 10000KB + (64KB * cardinality of split field * number or detectors) - const cardinality = resp[splitFieldName]; - mmlKB = 10000; - const SERIES_MULTIPLIER = 64; - const numberOfFields = fieldNames.length; - - if (cardinality !== undefined) { - mmlKB += SERIES_MULTIPLIER * cardinality * numberOfFields; - } - } else { - // the rest of the calculations are for influencers fields - // 10KB per series of influencer field - // i.e. 10KB * cardinality of influencer field - const cardinality = resp[splitFieldName]; - mmlKB = 0; - const SERIES_MULTIPLIER = 10; - if (cardinality !== undefined) { - mmlKB = SERIES_MULTIPLIER * cardinality; - } - } - // convert the total to MB, rounding up. - mmlMB += Math.ceil(mmlKB / MB); - }); - - // if max_model_memory_limit has been set, - // make sure the estimated value is not greater than it. - if (allowMMLGreaterThanMax === false && limits.max_model_memory_limit !== undefined) { - const maxBytes = numeral(limits.max_model_memory_limit.toUpperCase()).value(); - const mmlBytes = numeral(`${mmlMB}MB`).value(); - if (mmlBytes > maxBytes) { - mmlMB = Math.floor(maxBytes / numeral('1MB').value()); - } - } - response({ modelMemoryLimit: `${mmlMB}MB` }); - }) - .catch(error => { - reject(error); - }); - }); - }; -} diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts new file mode 100644 index 0000000000000..c97bbe07fffda --- /dev/null +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import numeral from '@elastic/numeral'; +import { APICaller } from 'kibana/server'; +import { AnalysisConfig } from '../../../common/types/anomaly_detection_jobs'; +import { fieldsServiceProvider } from '../fields_service'; + +interface ModelMemoryEstimationResult { + /** + * Result model memory limit + */ + modelMemoryLimit: string; + /** + * Estimated model memory by elasticsearch ml endpoint + */ + estimatedModelMemoryLimit: string; + /** + * Maximum model memory limit + */ + maxModelMemoryLimit?: string; +} + +/** + * Response of the _estimate_model_memory endpoint. + */ +export interface ModelMemoryEstimate { + model_memory_estimate: string; +} + +/** + * Retrieves overall and max bucket cardinalities. + */ +async function getCardinalities( + callAsCurrentUser: APICaller, + analysisConfig: AnalysisConfig, + indexPattern: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number +): Promise<{ + overallCardinality: { [key: string]: number }; + maxBucketCardinality: { [key: string]: number }; +}> { + /** + * Fields not involved in cardinality check + */ + const excludedKeywords = new Set( + /** + * The keyword which is used to mean the output of categorization, + * so it will have cardinality zero in the actual input data. + */ + 'mlcategory' + ); + + const fieldsService = fieldsServiceProvider(callAsCurrentUser); + + const { detectors, influencers, bucket_span: bucketSpan } = analysisConfig; + + let overallCardinality = {}; + let maxBucketCardinality = {}; + const overallCardinalityFields: Set = detectors.reduce( + ( + acc, + { + by_field_name: byFieldName, + partition_field_name: partitionFieldName, + over_field_name: overFieldName, + } + ) => { + [byFieldName, partitionFieldName, overFieldName] + .filter(field => field !== undefined && field !== '' && !excludedKeywords.has(field)) + .forEach(key => { + acc.add(key as string); + }); + return acc; + }, + new Set() + ); + + const maxBucketFieldCardinalities: string[] = influencers.filter( + influencerField => + typeof influencerField === 'string' && + !excludedKeywords.has(influencerField) && + !!influencerField && + !overallCardinalityFields.has(influencerField) + ) as string[]; + + if (overallCardinalityFields.size > 0) { + overallCardinality = await fieldsService.getCardinalityOfFields( + indexPattern, + [...overallCardinalityFields], + query, + timeFieldName, + earliestMs, + latestMs + ); + } + + if (maxBucketFieldCardinalities.length > 0) { + maxBucketCardinality = await fieldsService.getMaxBucketCardinalities( + indexPattern, + maxBucketFieldCardinalities, + query, + timeFieldName, + earliestMs, + latestMs, + bucketSpan + ); + } + + return { + overallCardinality, + maxBucketCardinality, + }; +} + +export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) { + /** + * Retrieves an estimated size of the model memory limit used in the job config + * based on the cardinality of the fields being used to split the data + * and influencers. + */ + return async function calculateModelMemoryLimit( + analysisConfig: AnalysisConfig, + indexPattern: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + allowMMLGreaterThanMax = false + ): Promise { + let maxModelMemoryLimit; + try { + const resp = await callAsCurrentUser('ml.info'); + if (resp?.limits?.max_model_memory_limit !== undefined) { + maxModelMemoryLimit = resp.limits.max_model_memory_limit.toUpperCase(); + } + } catch (e) { + throw new Error('Unable to retrieve max model memory limit'); + } + + const { overallCardinality, maxBucketCardinality } = await getCardinalities( + callAsCurrentUser, + analysisConfig, + indexPattern, + query, + timeFieldName, + earliestMs, + latestMs + ); + + const estimatedModelMemoryLimit = ( + await callAsCurrentUser('ml.estimateModelMemory', { + body: { + analysis_config: analysisConfig, + overall_cardinality: overallCardinality, + max_bucket_cardinality: maxBucketCardinality, + }, + }) + ).model_memory_estimate.toUpperCase(); + + let modelMemoryLimit: string = estimatedModelMemoryLimit; + // if max_model_memory_limit has been set, + // make sure the estimated value is not greater than it. + if (!allowMMLGreaterThanMax && maxModelMemoryLimit !== undefined) { + // @ts-ignore + const maxBytes = numeral(maxModelMemoryLimit).value(); + // @ts-ignore + const mmlBytes = numeral(estimatedModelMemoryLimit).value(); + if (mmlBytes > maxBytes) { + // @ts-ignore + modelMemoryLimit = `${Math.floor(maxBytes / numeral('1MB').value())}MB`; + } + } + + return { + estimatedModelMemoryLimit, + modelMemoryLimit, + ...(maxModelMemoryLimit ? { maxModelMemoryLimit } : {}), + }; + }; +} diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.d.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.d.ts deleted file mode 100644 index 4a7e57d290b17..0000000000000 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.d.ts +++ /dev/null @@ -1,21 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { APICaller } from 'kibana/server'; - -export function fieldsServiceProvider( - callAsCurrentUser: APICaller -): { - getCardinalityOfFields: ( - index: string[] | string, - fieldNames: string[], - query: any, - timeFieldName: string, - earliestMs: number, - latestMs: number - ) => Promise; - getTimeFieldRange: (index: string[] | string, timeFieldName: string, query: any) => Promise; -}; diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.js b/x-pack/plugins/ml/server/models/fields_service/fields_service.js deleted file mode 100644 index a538693a92aba..0000000000000 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.js +++ /dev/null @@ -1,148 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -// Service for carrying out queries to obtain data -// specific to fields in Elasticsearch indices. - -export function fieldsServiceProvider(callAsCurrentUser) { - // Obtains the cardinality of one or more fields. - // Returns an Object whose keys are the names of the fields, - // with values equal to the cardinality of the field. - // Any of the supplied fieldNames which are not aggregatable will - // be omitted from the returned Object. - function getCardinalityOfFields(index, fieldNames, query, timeFieldName, earliestMs, latestMs) { - // First check that each of the supplied fieldNames are aggregatable, - // then obtain the cardinality for each of the aggregatable fields. - return new Promise((resolve, reject) => { - callAsCurrentUser('fieldCaps', { - index, - fields: fieldNames, - }) - .then(fieldCapsResp => { - const aggregatableFields = []; - fieldNames.forEach(fieldName => { - const fieldInfo = fieldCapsResp.fields[fieldName]; - const typeKeys = fieldInfo !== undefined ? Object.keys(fieldInfo) : []; - if (typeKeys.length > 0) { - const fieldType = typeKeys[0]; - const isFieldAggregatable = fieldInfo[fieldType].aggregatable; - if (isFieldAggregatable === true) { - aggregatableFields.push(fieldName); - } - } - }); - - if (aggregatableFields.length > 0) { - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range and the datafeed config query. - const mustCriteria = [ - { - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (query) { - mustCriteria.push(query); - } - - const aggs = aggregatableFields.reduce((obj, field) => { - obj[field] = { cardinality: { field } }; - return obj; - }, {}); - - const body = { - query: { - bool: { - must: mustCriteria, - }, - }, - size: 0, - _source: { - excludes: [], - }, - aggs, - }; - - callAsCurrentUser('search', { - index, - body, - }) - .then(resp => { - const aggregations = resp.aggregations; - if (aggregations !== undefined) { - const results = aggregatableFields.reduce((obj, field) => { - obj[field] = (aggregations[field] || { value: 0 }).value; - return obj; - }, {}); - resolve(results); - } else { - resolve({}); - } - }) - .catch(resp => { - reject(resp); - }); - } else { - // None of the fields are aggregatable. Return empty Object. - resolve({}); - } - }) - .catch(resp => { - reject(resp); - }); - }); - } - - function getTimeFieldRange(index, timeFieldName, query) { - return new Promise((resolve, reject) => { - const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; - - callAsCurrentUser('search', { - index, - size: 0, - body: { - query, - aggs: { - earliest: { - min: { - field: timeFieldName, - }, - }, - latest: { - max: { - field: timeFieldName, - }, - }, - }, - }, - }) - .then(resp => { - if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { - obj.start.epoch = resp.aggregations.earliest.value; - obj.start.string = resp.aggregations.earliest.value_as_string; - - obj.end.epoch = resp.aggregations.latest.value; - obj.end.string = resp.aggregations.latest.value_as_string; - } - resolve(obj); - }) - .catch(resp => { - reject(resp); - }); - }); - } - - return { - getCardinalityOfFields, - getTimeFieldRange, - }; -} diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts new file mode 100644 index 0000000000000..d16984abc5d2a --- /dev/null +++ b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts @@ -0,0 +1,296 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { APICaller } from 'kibana/server'; +import { parseInterval } from '../../../common/util/parse_interval'; + +/** + * Service for carrying out queries to obtain data + * specific to fields in Elasticsearch indices. + */ +export function fieldsServiceProvider(callAsCurrentUser: APICaller) { + /** + * Gets aggregatable fields. + */ + async function getAggregatableFields( + index: string | string[], + fieldNames: string[] + ): Promise { + const fieldCapsResp = await callAsCurrentUser('fieldCaps', { + index, + fields: fieldNames, + }); + const aggregatableFields: string[] = []; + fieldNames.forEach(fieldName => { + const fieldInfo = fieldCapsResp.fields[fieldName]; + const typeKeys = fieldInfo !== undefined ? Object.keys(fieldInfo) : []; + if (typeKeys.length > 0) { + const fieldType = typeKeys[0]; + const isFieldAggregatable = fieldInfo[fieldType].aggregatable; + if (isFieldAggregatable === true) { + aggregatableFields.push(fieldName); + } + } + }); + return aggregatableFields; + } + + // Obtains the cardinality of one or more fields. + // Returns an Object whose keys are the names of the fields, + // with values equal to the cardinality of the field. + // Any of the supplied fieldNames which are not aggregatable will + // be omitted from the returned Object. + async function getCardinalityOfFields( + index: string[] | string, + fieldNames: string[], + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number + ): Promise<{ [key: string]: number }> { + const aggregatableFields = await getAggregatableFields(index, fieldNames); + + if (aggregatableFields.length === 0) { + return {}; + } + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range and the datafeed config query. + const mustCriteria = [ + { + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + ]; + + if (query) { + mustCriteria.push(query); + } + + const aggs = aggregatableFields.reduce((obj, field) => { + obj[field] = { cardinality: { field } }; + return obj; + }, {} as { [field: string]: { cardinality: { field: string } } }); + + const body = { + query: { + bool: { + must: mustCriteria, + }, + }, + size: 0, + _source: { + excludes: [], + }, + aggs, + }; + + const aggregations = ( + await callAsCurrentUser('search', { + index, + body, + }) + )?.aggregations; + + if (!aggregations) { + return {}; + } + + return aggregatableFields.reduce((obj, field) => { + obj[field] = (aggregations[field] || { value: 0 }).value; + return obj; + }, {} as { [field: string]: number }); + } + + function getTimeFieldRange( + index: string[] | string, + timeFieldName: string, + query: any + ): Promise { + return new Promise((resolve, reject) => { + const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; + + callAsCurrentUser('search', { + index, + size: 0, + body: { + query, + aggs: { + earliest: { + min: { + field: timeFieldName, + }, + }, + latest: { + max: { + field: timeFieldName, + }, + }, + }, + }, + }) + .then(resp => { + if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { + obj.start.epoch = resp.aggregations.earliest.value; + obj.start.string = resp.aggregations.earliest.value_as_string; + + obj.end.epoch = resp.aggregations.latest.value; + obj.end.string = resp.aggregations.latest.value_as_string; + } + resolve(obj); + }) + .catch(resp => { + reject(resp); + }); + }); + } + + /** + * Caps provided time boundaries based on the interval. + * @param earliestMs + * @param latestMs + * @param interval + */ + function getSafeTimeRange( + earliestMs: number, + latestMs: number, + interval: string + ): { start: number; end: number } { + const maxNumberOfBuckets = 1000; + const end = latestMs; + + const intervalDuration = parseInterval(interval); + + if (intervalDuration === null) { + throw Boom.badRequest('Interval is invalid'); + } + + const start = Math.max( + earliestMs, + latestMs - maxNumberOfBuckets * intervalDuration.asMilliseconds() + ); + + return { start, end }; + } + + /** + * Retrieves max cardinalities for provided fields from date interval buckets + * using max bucket pipeline aggregation. + * + * @param index + * @param fieldNames - fields to perform cardinality aggregation on + * @param query + * @param timeFieldName + * @param earliestMs + * @param latestMs + * @param interval - a fixed interval for the date histogram aggregation + */ + async function getMaxBucketCardinalities( + index: string[] | string, + fieldNames: string[], + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string | undefined + ): Promise<{ [key: string]: number }> { + if (!interval) { + throw new Error('Interval is required to retrieve max bucket cardinalities.'); + } + + const aggregatableFields = await getAggregatableFields(index, fieldNames); + + if (aggregatableFields.length === 0) { + return {}; + } + + const { start, end } = getSafeTimeRange(earliestMs, latestMs, interval); + + const mustCriteria = [ + { + range: { + [timeFieldName]: { + gte: start, + lte: end, + format: 'epoch_millis', + }, + }, + }, + ]; + + if (query) { + mustCriteria.push(query); + } + + const dateHistogramAggKey = 'bucket_span_buckets'; + /** + * Replace any non-word characters + */ + const getSafeAggName = (field: string) => field.replace(/\W/g, ''); + const getMaxBucketAggKey = (field: string) => `max_bucket_${field}`; + + const fieldsCardinalityAggs = aggregatableFields.reduce((obj, field) => { + obj[getSafeAggName(field)] = { cardinality: { field } }; + return obj; + }, {} as { [field: string]: { cardinality: { field: string } } }); + + const maxBucketCardinalitiesAggs = Object.keys(fieldsCardinalityAggs).reduce((acc, field) => { + acc[getMaxBucketAggKey(field)] = { + max_bucket: { + buckets_path: `${dateHistogramAggKey}>${field}`, + }, + }; + return acc; + }, {} as { [key: string]: { max_bucket: { buckets_path: string } } }); + + const body = { + query: { + bool: { + filter: mustCriteria, + }, + }, + size: 0, + aggs: { + [dateHistogramAggKey]: { + date_histogram: { + field: timeFieldName, + fixed_interval: interval, + }, + aggs: fieldsCardinalityAggs, + }, + ...maxBucketCardinalitiesAggs, + }, + }; + + const aggregations = ( + await callAsCurrentUser('search', { + index, + body, + }) + )?.aggregations; + + if (!aggregations) { + return {}; + } + + return aggregatableFields.reduce((obj, field) => { + obj[field] = (aggregations[getMaxBucketAggKey(field)] || { value: 0 }).value ?? 0; + return obj; + }, {} as { [field: string]: number }); + } + + return { + getCardinalityOfFields, + getTimeFieldRange, + getMaxBucketCardinalities, + }; +} diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_job_object.js b/x-pack/plugins/ml/server/models/job_validation/validate_job_object.ts similarity index 96% rename from x-pack/plugins/ml/server/models/job_validation/validate_job_object.js rename to x-pack/plugins/ml/server/models/job_validation/validate_job_object.ts index 3205aba4fac4d..b0271fb5b4f45 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_job_object.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_job_object.ts @@ -5,8 +5,9 @@ */ import { i18n } from '@kbn/i18n'; +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; -export function validateJobObject(job) { +export function validateJobObject(job: CombinedJob | null) { if (job === null || typeof job !== 'object') { throw new Error( i18n.translate('xpack.ml.models.jobValidation.validateJobObject.jobIsNotObjectErrorMessage', { diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.js b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.js deleted file mode 100644 index 733ed9c3c22c6..0000000000000 --- a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.js +++ /dev/null @@ -1,170 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import numeral from '@elastic/numeral'; -import { validateJobObject } from './validate_job_object'; -import { calculateModelMemoryLimitProvider } from '../../models/calculate_model_memory_limit'; -import { ALLOWED_DATA_UNITS } from '../../../common/constants/validation'; - -// The minimum value the backend expects is 1MByte -const MODEL_MEMORY_LIMIT_MINIMUM_BYTES = 1048576; - -export async function validateModelMemoryLimit(callWithRequest, job, duration) { - validateJobObject(job); - - // retrieve the max_model_memory_limit value from the server - // this will be unset unless the user has set this on their cluster - const mlInfo = await callWithRequest('ml.info'); - const maxModelMemoryLimit = - typeof mlInfo.limits === 'undefined' ? undefined : mlInfo.limits.max_model_memory_limit; - - // retrieve the model memory limit specified by the user in the job config. - // note, this will probably be the auto generated value, unless the user has - // over written it. - const mml = - typeof job.analysis_limits !== 'undefined' && - typeof job.analysis_limits.model_memory_limit !== 'undefined' - ? job.analysis_limits.model_memory_limit.toUpperCase() - : null; - - const splitFieldNames = {}; - let splitFieldName = ''; - const fieldNames = []; - let runCalcModelMemoryTest = true; - let validModelMemoryLimit = true; - - // extract the field names and partition field names from the detectors - // we only want to estimate the mml for multi-metric jobs. - // a multi-metric job will have one partition field, one or more field names - // and no over or by fields - job.analysis_config.detectors.forEach(d => { - if (typeof d.field_name !== 'undefined') { - fieldNames.push(d.field_name); - } - - // create a deduplicated list of partition field names. - if (typeof d.partition_field_name !== 'undefined') { - splitFieldNames[d.partition_field_name] = null; - } - - // if an over or by field is present, do not run the estimate test - if (typeof d.over_field_name !== 'undefined' || typeof d.by_field_name !== 'undefined') { - runCalcModelMemoryTest = false; - } - }); - - // if there are no or more than one partition fields, do not run the test - if (Object.keys(splitFieldNames).length === 1) { - splitFieldName = Object.keys(splitFieldNames)[0]; - } else { - runCalcModelMemoryTest = false; - } - - // if there is no duration, do not run the estimate test - if ( - typeof duration === 'undefined' || - typeof duration.start === 'undefined' || - typeof duration.end === 'undefined' - ) { - runCalcModelMemoryTest = false; - } - - const messages = []; - - // check that mml is a valid data format - if (mml !== null) { - const mmlSplit = mml.match(/\d+(\w+)/); - const unit = mmlSplit && mmlSplit.length === 2 ? mmlSplit[1] : null; - - if (ALLOWED_DATA_UNITS.indexOf(unit) === -1) { - messages.push({ - id: 'mml_value_invalid', - mml, - }); - // mml is not a valid data format. - // abort all other tests - validModelMemoryLimit = false; - } - } - - if (validModelMemoryLimit) { - if (runCalcModelMemoryTest) { - const mmlEstimate = await calculateModelMemoryLimitProvider(callWithRequest)( - job.datafeed_config.indices.join(','), - splitFieldName, - job.datafeed_config.query, - fieldNames, - job.analysis_config.influencers, - job.data_description.time_field, - duration.start, - duration.end, - true - ); - const mmlEstimateBytes = numeral(mmlEstimate.modelMemoryLimit).value(); - - let runEstimateGreaterThenMml = true; - // if max_model_memory_limit has been set, - // make sure the estimated value is not greater than it. - if (typeof maxModelMemoryLimit !== 'undefined') { - const maxMmlBytes = numeral(maxModelMemoryLimit.toUpperCase()).value(); - if (mmlEstimateBytes > maxMmlBytes) { - runEstimateGreaterThenMml = false; - messages.push({ - id: 'estimated_mml_greater_than_max_mml', - maxModelMemoryLimit, - mmlEstimate, - }); - } - } - - // check to see if the estimated mml is greater that the user - // specified mml - // do not run this if we've already found that it's larger than - // the max mml - if (runEstimateGreaterThenMml && mml !== null) { - const mmlBytes = numeral(mml).value(); - if (mmlBytes < MODEL_MEMORY_LIMIT_MINIMUM_BYTES) { - messages.push({ - id: 'mml_value_invalid', - mml, - }); - } else if (mmlEstimateBytes / 2 > mmlBytes) { - messages.push({ - id: 'half_estimated_mml_greater_than_mml', - maxModelMemoryLimit, - mml, - }); - } else if (mmlEstimateBytes > mmlBytes) { - messages.push({ - id: 'estimated_mml_greater_than_mml', - maxModelMemoryLimit, - mml, - }); - } - } - } - - // if max_model_memory_limit has been set, - // make sure the user defined MML is not greater than it - if (maxModelMemoryLimit !== undefined && mml !== null) { - const maxMmlBytes = numeral(maxModelMemoryLimit.toUpperCase()).value(); - const mmlBytes = numeral(mml).value(); - if (mmlBytes > maxMmlBytes) { - messages.push({ - id: 'mml_greater_than_max_mml', - maxModelMemoryLimit, - mml, - }); - } - } - } - - if (messages.length === 0 && runCalcModelMemoryTest === true) { - messages.push({ id: 'success_mml' }); - } - - return Promise.resolve(messages); -} diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_model_memory_limit.js b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts similarity index 60% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_model_memory_limit.js rename to x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts index f2459fa339005..6b5d5614325bf 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_model_memory_limit.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateModelMemoryLimit } from '../validate_model_memory_limit'; +import { APICaller } from 'kibana/server'; +import { CombinedJob, Detector } from '../../../common/types/anomaly_detection_jobs'; +import { ModelMemoryEstimate } from '../calculate_model_memory_limit/calculate_model_memory_limit'; +import { validateModelMemoryLimit } from './validate_model_memory_limit'; describe('ML - validateModelMemoryLimit', () => { // mock info endpoint response @@ -61,29 +63,43 @@ describe('ML - validateModelMemoryLimit', () => { }, }; + // mock estimate model memory + const modelMemoryEstimateResponse: ModelMemoryEstimate = { + model_memory_estimate: '40mb', + }; + + interface MockAPICallResponse { + 'ml.estimateModelMemory'?: ModelMemoryEstimate; + } + // mock callWithRequest // used in three places: // - to retrieve the info endpoint // - to search for cardinality of split field // - to retrieve field capabilities used in search for split field cardinality - function callWithRequest(call) { - if (typeof call === undefined) { - return Promise.reject(); - } - - let response = {}; - if (call === 'ml.info') { - response = mlInfoResponse; - } else if (call === 'search') { - response = cardinalitySearchResponse; - } else if (call === 'fieldCaps') { - response = fieldCapsResponse; - } - return Promise.resolve(response); - } - - function getJobConfig(influencers = [], detectors = []) { - return { + const getMockCallWithRequest = ({ + 'ml.estimateModelMemory': estimateModelMemory, + }: MockAPICallResponse = {}) => + ((call: string) => { + if (typeof call === undefined) { + return Promise.reject(); + } + + let response = {}; + if (call === 'ml.info') { + response = mlInfoResponse; + } else if (call === 'search') { + response = cardinalitySearchResponse; + } else if (call === 'fieldCaps') { + response = fieldCapsResponse; + } else if (call === 'ml.estimateModelMemory') { + response = estimateModelMemory || modelMemoryEstimateResponse; + } + return Promise.resolve(response); + }) as APICaller; + + function getJobConfig(influencers: string[] = [], detectors: Detector[] = []) { + return ({ analysis_config: { detectors, influencers }, data_description: { time_field: '@timestamp' }, datafeed_config: { @@ -92,11 +108,11 @@ describe('ML - validateModelMemoryLimit', () => { analysis_limits: { model_memory_limit: '20mb', }, - }; + } as unknown) as CombinedJob; } // create a specified number of mock detectors - function createDetectors(numberOfDetectors) { + function createDetectors(numberOfDetectors: number): Detector[] { const dtrs = []; for (let i = 0; i < numberOfDetectors; i++) { dtrs.push({ @@ -105,28 +121,28 @@ describe('ML - validateModelMemoryLimit', () => { partition_field_name: 'instance', }); } - return dtrs; + return dtrs as Detector[]; } - // tests it('Called with no duration or split and mml within limit', () => { const job = getJobConfig(); const duration = undefined; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toEqual([]); }); }); it('Called with no duration or split and mml above limit', () => { const job = getJobConfig(); const duration = undefined; + // @ts-ignore job.analysis_limits.model_memory_limit = '31mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_greater_than_max_mml']); + expect(ids).toEqual(['mml_greater_than_max_mml']); }); }); @@ -134,11 +150,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(10); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '20mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '66mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['estimated_mml_greater_than_max_mml']); + expect(ids).toEqual(['estimated_mml_greater_than_max_mml']); }); }); @@ -146,11 +167,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(2); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '30mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '24mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_mml']); + expect(ids).toEqual(['success_mml']); }); }); @@ -158,11 +184,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(2); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '10mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '22mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -171,11 +202,12 @@ describe('ML - validateModelMemoryLimit', () => { const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; delete mlInfoResponse.limits.max_model_memory_limit; + // @ts-ignore job.analysis_limits.model_memory_limit = '10mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -183,11 +215,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '20mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '19mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_mml']); + expect(ids).toEqual(['success_mml']); }); }); @@ -195,11 +232,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '0mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -207,11 +245,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '10mbananas'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -219,11 +258,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '10'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -231,11 +271,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = 'mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -243,11 +284,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = 'asdf'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -255,11 +297,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '1023KB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -267,11 +310,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '1024KB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -279,11 +323,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '6MB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -291,11 +336,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '20MB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '20mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_mml']); + expect(ids).toEqual(['success_mml']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts new file mode 100644 index 0000000000000..0c431f6a07563 --- /dev/null +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import numeral from '@elastic/numeral'; +import { APICaller } from 'kibana/server'; +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import { validateJobObject } from './validate_job_object'; +import { calculateModelMemoryLimitProvider } from '../calculate_model_memory_limit'; +import { ALLOWED_DATA_UNITS } from '../../../common/constants/validation'; + +// The minimum value the backend expects is 1MByte +const MODEL_MEMORY_LIMIT_MINIMUM_BYTES = 1048576; + +export async function validateModelMemoryLimit( + callWithRequest: APICaller, + job: CombinedJob, + duration?: { start?: number; end?: number } +) { + validateJobObject(job); + + // retrieve the model memory limit specified by the user in the job config. + // note, this will probably be the auto generated value, unless the user has + // over written it. + const mml = job?.analysis_limits?.model_memory_limit?.toUpperCase() ?? null; + + const messages = []; + + // check that mml is a valid data format + if (mml !== null) { + const mmlSplit = mml.match(/\d+(\w+)/); + const unit = mmlSplit && mmlSplit.length === 2 ? mmlSplit[1] : null; + + if (unit === null || !ALLOWED_DATA_UNITS.includes(unit)) { + messages.push({ + id: 'mml_value_invalid', + mml, + }); + // mml is not a valid data format. + // abort all other tests + return messages; + } + } + + // if there is no duration, do not run the estimate test + const runCalcModelMemoryTest = + duration && typeof duration?.start !== undefined && duration?.end !== undefined; + + // retrieve the max_model_memory_limit value from the server + // this will be unset unless the user has set this on their cluster + const maxModelMemoryLimit: string | undefined = ( + await callWithRequest('ml.info') + )?.limits?.max_model_memory_limit?.toUpperCase(); + + if (runCalcModelMemoryTest) { + const { modelMemoryLimit } = await calculateModelMemoryLimitProvider(callWithRequest)( + job.analysis_config, + job.datafeed_config.indices.join(','), + job.datafeed_config.query, + job.data_description.time_field, + duration!.start as number, + duration!.end as number, + true + ); + // @ts-ignore + const mmlEstimateBytes: number = numeral(modelMemoryLimit).value(); + + let runEstimateGreaterThenMml = true; + // if max_model_memory_limit has been set, + // make sure the estimated value is not greater than it. + if (typeof maxModelMemoryLimit !== 'undefined') { + // @ts-ignore + const maxMmlBytes: number = numeral(maxModelMemoryLimit).value(); + if (mmlEstimateBytes > maxMmlBytes) { + runEstimateGreaterThenMml = false; + messages.push({ + id: 'estimated_mml_greater_than_max_mml', + maxModelMemoryLimit, + modelMemoryLimit, + }); + } + } + + // check to see if the estimated mml is greater that the user + // specified mml + // do not run this if we've already found that it's larger than + // the max mml + if (runEstimateGreaterThenMml && mml !== null) { + // @ts-ignore + const mmlBytes: number = numeral(mml).value(); + if (mmlBytes < MODEL_MEMORY_LIMIT_MINIMUM_BYTES) { + messages.push({ + id: 'mml_value_invalid', + mml, + }); + } else if (mmlEstimateBytes / 2 > mmlBytes) { + messages.push({ + id: 'half_estimated_mml_greater_than_mml', + maxModelMemoryLimit, + mml, + }); + } else if (mmlEstimateBytes > mmlBytes) { + messages.push({ + id: 'estimated_mml_greater_than_mml', + maxModelMemoryLimit, + mml, + }); + } + } + } + + // if max_model_memory_limit has been set, + // make sure the user defined MML is not greater than it + if (maxModelMemoryLimit !== undefined && mml !== null) { + // @ts-ignore + const maxMmlBytes = numeral(maxModelMemoryLimit).value(); + // @ts-ignore + const mmlBytes = numeral(mml).value(); + if (mmlBytes > maxMmlBytes) { + messages.push({ + id: 'mml_greater_than_max_mml', + maxModelMemoryLimit, + mml, + }); + } + } + + if (messages.length === 0 && runCalcModelMemoryTest === true) { + messages.push({ id: 'success_mml' }); + } + + return messages; +} diff --git a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts index c6bb62aa34916..d03e76072c315 100644 --- a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts @@ -148,7 +148,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { params: schema.object({ jobId: schema.string(), }), - body: schema.object({ ...anomalyDetectionJobSchema }), + body: schema.object(anomalyDetectionJobSchema), }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/job_validation.ts b/x-pack/plugins/ml/server/routes/job_validation.ts index fd5c8dc7e9a7a..75d9cdf375049 100644 --- a/x-pack/plugins/ml/server/routes/job_validation.ts +++ b/x-pack/plugins/ml/server/routes/job_validation.ts @@ -7,6 +7,7 @@ import Boom from 'boom'; import { RequestHandlerContext } from 'kibana/server'; import { schema, TypeOf } from '@kbn/config-schema'; +import { AnalysisConfig } from '../../common/types/anomaly_detection_jobs'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { @@ -29,23 +30,12 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, context: RequestHandlerContext, payload: CalculateModelMemoryLimitPayload ) { - const { - indexPattern, - splitFieldName, - query, - fieldNames, - influencerNames, - timeFieldName, - earliestMs, - latestMs, - } = payload; + const { analysisConfig, indexPattern, query, timeFieldName, earliestMs, latestMs } = payload; return calculateModelMemoryLimitProvider(context.ml!.mlClient.callAsCurrentUser)( + analysisConfig as AnalysisConfig, indexPattern, - splitFieldName, query, - fieldNames, - influencerNames, timeFieldName, earliestMs, latestMs @@ -102,7 +92,7 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, * * @api {post} /api/ml/validate/calculate_model_memory_limit Calculates model memory limit * @apiName CalculateModelMemoryLimit - * @apiDescription Calculates the model memory limit + * @apiDescription Calls _estimate_model_memory endpoint to retrieve model memory estimation. * * @apiSuccess {String} modelMemoryLimit */ diff --git a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts index a46ccd8664a62..6002bb218c41b 100644 --- a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts @@ -63,14 +63,16 @@ export const anomalyDetectionUpdateJobSchema = { groups: schema.maybe(schema.arrayOf(schema.maybe(schema.string()))), }; +export const analysisConfigSchema = schema.object({ + bucket_span: schema.maybe(schema.string()), + summary_count_field_name: schema.maybe(schema.string()), + detectors: schema.arrayOf(detectorSchema), + influencers: schema.arrayOf(schema.maybe(schema.string())), + categorization_field_name: schema.maybe(schema.string()), +}); + export const anomalyDetectionJobSchema = { - analysis_config: schema.object({ - bucket_span: schema.maybe(schema.string()), - summary_count_field_name: schema.maybe(schema.string()), - detectors: schema.arrayOf(detectorSchema), - influencers: schema.arrayOf(schema.maybe(schema.string())), - categorization_field_name: schema.maybe(schema.string()), - }), + analysis_config: analysisConfigSchema, analysis_limits: schema.maybe( schema.object({ categorization_examples_limit: schema.maybe(schema.number()), diff --git a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts index 5da825a905e8d..3ded6e770eed5 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { anomalyDetectionJobSchema } from './anomaly_detectors_schema'; +import { analysisConfigSchema, anomalyDetectionJobSchema } from './anomaly_detectors_schema'; import { datafeedConfigSchema } from './datafeeds_schema'; export const estimateBucketSpanSchema = schema.object({ @@ -20,11 +20,9 @@ export const estimateBucketSpanSchema = schema.object({ }); export const modelMemoryLimitSchema = schema.object({ + analysisConfig: analysisConfigSchema, indexPattern: schema.string(), - splitFieldName: schema.string(), query: schema.any(), - fieldNames: schema.arrayOf(schema.string()), - influencerNames: schema.arrayOf(schema.maybe(schema.string())), timeFieldName: schema.string(), earliestMs: schema.number(), latestMs: schema.number(), diff --git a/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts b/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts index e67d87ca37c01..5d1a52e3c2c21 100644 --- a/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts +++ b/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts @@ -21,14 +21,20 @@ export default ({ getService }: FtrProviderContext) => { const testDataList = [ { - testTitleSuffix: 'with 0 metrics, 0 influencers and no split field', + testTitleSuffix: 'when no partition field is provided with regular function', user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: '', + analysisConfig: { + bucket_span: '15m', + detectors: [ + { + function: 'mean', + }, + ], + influencers: [], + }, query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: ['__ml_event_rate_count__'], - influencerNames: [], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, @@ -38,7 +44,8 @@ export default ({ getService }: FtrProviderContext) => { responseBody: { statusCode: 400, error: 'Bad Request', - message: "[illegal_argument_exception] specified fields can't be null or empty", + message: + '[status_exception] Unless a count or temporal function is used one of field_name, by_field_name or over_field_name must be set', }, }, }, @@ -47,72 +54,79 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: 'geoip.city_name', - query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: ['products.base_price'], - influencerNames: ['geoip.city_name'], - timeFieldName: 'order_date', - earliestMs: 1560297859000, - latestMs: 1562975136000, - }, - expected: { - responseCode: 200, - responseBody: { modelMemoryLimit: '12MB' }, - }, - }, - { - testTitleSuffix: 'with 3 metrics, 3 influencers, split by city', - user: USER.ML_POWERUSER, - requestBody: { - indexPattern: 'ecommerce', - splitFieldName: 'geoip.city_name', + analysisConfig: { + bucket_span: '15m', + detectors: [ + { + function: 'avg', + field_name: 'geoip.city_name', + by_field_name: 'geoip.city_name', + }, + ], + influencers: ['geoip.city_name'], + }, query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: ['products.base_price', 'taxful_total_price', 'products.discount_amount'], - influencerNames: ['geoip.city_name', 'customer_gender', 'customer_full_name.keyword'], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, }, expected: { responseCode: 200, - responseBody: { modelMemoryLimit: '14MB' }, + responseBody: { modelMemoryLimit: '11MB', estimatedModelMemoryLimit: '11MB' }, }, }, { - testTitleSuffix: 'with 4 metrics, 4 influencers, split by customer_id', + testTitleSuffix: 'with 3 influencers, split by city', user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: 'customer_id', + analysisConfig: { + bucket_span: '15m', + detectors: [ + { + function: 'mean', + by_field_name: 'geoip.city_name', + field_name: 'geoip.city_name', + }, + ], + influencers: ['geoip.city_name', 'customer_gender', 'customer_full_name.keyword'], + }, query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: [ - 'geoip.country_iso_code', - 'taxless_total_price', - 'taxful_total_price', - 'products.discount_amount', - ], - influencerNames: [ - 'customer_id', - 'geoip.country_iso_code', - 'products.discount_percentage', - 'products.discount_amount', - ], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, }, expected: { responseCode: 200, - responseBody: { modelMemoryLimit: '23MB' }, + responseBody: { estimatedModelMemoryLimit: '11MB', modelMemoryLimit: '11MB' }, }, }, { - testTitleSuffix: - 'with 4 metrics, 4 influencers, split by customer_id and filtering by country code', + testTitleSuffix: '4 influencers, split by customer_id and filtering by country code', user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: 'customer_id', + analysisConfig: { + bucket_span: '2d', + detectors: [ + { + function: 'mean', + by_field_name: 'customer_id.city_name', + field_name: 'customer_id.city_name', + }, + { + function: 'avg', + by_field_name: 'manufacturer.keyword', + field_name: 'manufacturer.keyword', + }, + ], + influencers: [ + 'geoip.country_iso_code', + 'products.discount_percentage', + 'products.discount_amount', + 'day_of_week', + ], + }, query: { bool: { filter: { @@ -122,25 +136,13 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, - fieldNames: [ - 'geoip.country_iso_code', - 'taxless_total_price', - 'taxful_total_price', - 'products.discount_amount', - ], - influencerNames: [ - 'customer_id', - 'geoip.country_iso_code', - 'products.discount_percentage', - 'products.discount_amount', - ], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, }, expected: { responseCode: 200, - responseBody: { modelMemoryLimit: '14MB' }, + responseBody: { estimatedModelMemoryLimit: '12MB', modelMemoryLimit: '12MB' }, }, }, ]; From fe4c164681e92ef5bf0c28f7ab3dfe00a5aacd6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez?= Date: Thu, 19 Mar 2020 17:19:21 +0100 Subject: [PATCH 08/13] [Logs UI] Use the Super date picker in the log stream (#54280) --- .../common/http_api/log_entries/entries.ts | 72 ++-- .../common/http_api/log_entries/highlights.ts | 4 +- .../common/http_api/log_entries/summary.ts | 4 +- .../components/logging/log_datepicker.tsx | 73 ++++ .../logging/log_minimap/density_chart.tsx | 42 +- .../log_minimap/highlighted_interval.tsx | 6 +- .../logging/log_minimap/log_minimap.tsx | 191 ++------- .../logging/log_minimap/search_marker.tsx | 5 +- .../logging/log_minimap/search_markers.tsx | 4 +- .../log_minimap/time_label_formatter.tsx | 23 + .../logging/log_minimap/time_ruler.tsx | 43 +- .../components/logging/log_minimap/types.ts | 17 - .../logging/log_minimap_scale_controls.tsx | 67 --- .../logging/log_text_stream/item.ts | 8 +- .../log_text_stream/loading_item_view.tsx | 349 ++++++++++----- .../log_entry_field_column.test.tsx | 19 +- .../log_entry_field_column.tsx | 23 +- .../log_entry_message_column.tsx | 35 +- .../logging/log_text_stream/log_entry_row.tsx | 16 +- .../log_text_stream/log_text_separator.tsx | 21 + .../scrollable_log_text_stream_view.tsx | 162 +++---- .../components/logging/log_time_controls.tsx | 97 ----- .../logs/log_entries/api/fetch_log_entries.ts | 28 ++ .../logs/log_entries/gql_queries.ts | 64 --- .../containers/logs/log_entries/index.ts | 301 +++++++++---- .../public/containers/logs/log_flyout.tsx | 2 +- .../api/fetch_log_entries_highlights.ts | 31 ++ .../log_highlights/log_entry_highlights.tsx | 80 ++-- .../logs/log_highlights/log_highlights.tsx | 50 +-- .../log_highlights/log_summary_highlights.ts | 26 +- .../logs/log_highlights/next_and_previous.tsx | 4 +- .../logs/log_position/log_position_state.ts | 111 ++++- .../with_log_position_url_state.tsx | 95 ++++- .../logs/log_summary/bucket_size.ts | 23 + .../containers/logs/log_summary/index.ts | 1 - .../logs/log_summary/log_summary.test.tsx | 89 ++-- .../logs/log_summary/log_summary.tsx | 23 +- .../use_log_summary_buffer_interval.ts | 30 -- .../logs/log_summary/with_summary.ts | 15 +- .../logs/log_view_configuration.test.tsx | 25 -- .../logs/log_view_configuration.tsx | 46 -- .../containers/logs/with_log_minimap.tsx | 52 --- .../containers/logs/with_stream_items.ts | 6 +- .../category_example_message.tsx | 10 +- .../pages/logs/stream/page_logs_content.tsx | 21 +- .../pages/logs/stream/page_providers.tsx | 32 +- .../public/pages/logs/stream/page_toolbar.tsx | 45 +- .../infra/public/utils/datemath.test.ts | 401 ++++++++++++++++++ x-pack/plugins/infra/public/utils/datemath.ts | 266 ++++++++++++ .../infra/public/utils/log_entry/log_entry.ts | 41 +- .../utils/log_entry/log_entry_highlight.ts | 26 +- x-pack/plugins/infra/server/graphql/index.ts | 9 +- .../infra/server/graphql/log_entries/index.ts | 7 - .../server/graphql/log_entries/resolvers.ts | 175 -------- .../server/graphql/log_entries/schema.gql.ts | 136 ------ x-pack/plugins/infra/server/infra_server.ts | 2 - .../log_entries/kibana_log_entries_adapter.ts | 218 +--------- .../log_entries_domain/log_entries_domain.ts | 263 +----------- .../server/routes/log_entries/entries.ts | 22 +- .../server/routes/log_entries/highlights.ts | 10 +- .../server/routes/log_entries/summary.ts | 10 +- .../routes/log_entries/summary_highlights.ts | 17 +- .../translations/translations/ja-JP.json | 14 - .../translations/translations/zh-CN.json | 14 - .../test/api_integration/apis/infra/index.js | 2 +- .../api_integration/apis/infra/log_entries.ts | 392 +++++------------ .../apis/infra/log_entry_highlights.ts | 256 +---------- .../api_integration/apis/infra/log_summary.ts | 11 +- .../apis/infra/logs_without_millis.ts | 221 ++++------ .../test/functional/apps/infra/constants.ts | 4 + x-pack/test/functional/apps/infra/link_to.ts | 13 +- .../apps/infra/logs_source_configuration.ts | 15 +- .../page_objects/infra_logs_page.ts | 37 +- .../functional/services/logs_ui/log_stream.ts | 5 +- 74 files changed, 2354 insertions(+), 2724 deletions(-) create mode 100644 x-pack/plugins/infra/public/components/logging/log_datepicker.tsx create mode 100644 x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx delete mode 100644 x-pack/plugins/infra/public/components/logging/log_minimap/types.ts delete mode 100644 x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx create mode 100644 x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx delete mode 100644 x-pack/plugins/infra/public/components/logging/log_time_controls.tsx create mode 100644 x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts create mode 100644 x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts create mode 100644 x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx create mode 100644 x-pack/plugins/infra/public/utils/datemath.test.ts create mode 100644 x-pack/plugins/infra/public/utils/datemath.ts delete mode 100644 x-pack/plugins/infra/server/graphql/log_entries/index.ts delete mode 100644 x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts delete mode 100644 x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts diff --git a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts index 97bdad23beb24..419ee021a9189 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts @@ -12,11 +12,11 @@ export const LOG_ENTRIES_PATH = '/api/log_entries/entries'; export const logEntriesBaseRequestRT = rt.intersection([ rt.type({ sourceId: rt.string, - startDate: rt.number, - endDate: rt.number, + startTimestamp: rt.number, + endTimestamp: rt.number, }), rt.partial({ - query: rt.string, + query: rt.union([rt.string, rt.null]), size: rt.number, }), ]); @@ -31,7 +31,7 @@ export const logEntriesAfterRequestRT = rt.intersection([ rt.type({ after: rt.union([logEntriesCursorRT, rt.literal('first')]) }), ]); -export const logEntriesCenteredRT = rt.intersection([ +export const logEntriesCenteredRequestRT = rt.intersection([ logEntriesBaseRequestRT, rt.type({ center: logEntriesCursorRT }), ]); @@ -40,38 +40,39 @@ export const logEntriesRequestRT = rt.union([ logEntriesBaseRequestRT, logEntriesBeforeRequestRT, logEntriesAfterRequestRT, - logEntriesCenteredRT, + logEntriesCenteredRequestRT, ]); +export type LogEntriesBaseRequest = rt.TypeOf; +export type LogEntriesBeforeRequest = rt.TypeOf; +export type LogEntriesAfterRequest = rt.TypeOf; +export type LogEntriesCenteredRequest = rt.TypeOf; export type LogEntriesRequest = rt.TypeOf; -// JSON value -const valueRT = rt.union([rt.string, rt.number, rt.boolean, rt.object, rt.null, rt.undefined]); +export const logMessageConstantPartRT = rt.type({ + constant: rt.string, +}); +export const logMessageFieldPartRT = rt.type({ + field: rt.string, + value: rt.unknown, + highlights: rt.array(rt.string), +}); -export const logMessagePartRT = rt.union([ - rt.type({ - constant: rt.string, - }), - rt.type({ - field: rt.string, - value: valueRT, - highlights: rt.array(rt.string), - }), -]); +export const logMessagePartRT = rt.union([logMessageConstantPartRT, logMessageFieldPartRT]); -export const logColumnRT = rt.union([ - rt.type({ columnId: rt.string, timestamp: rt.number }), - rt.type({ - columnId: rt.string, - field: rt.string, - value: rt.union([rt.string, rt.undefined]), - highlights: rt.array(rt.string), - }), - rt.type({ - columnId: rt.string, - message: rt.array(logMessagePartRT), - }), -]); +export const logTimestampColumnRT = rt.type({ columnId: rt.string, timestamp: rt.number }); +export const logFieldColumnRT = rt.type({ + columnId: rt.string, + field: rt.string, + value: rt.unknown, + highlights: rt.array(rt.string), +}); +export const logMessageColumnRT = rt.type({ + columnId: rt.string, + message: rt.array(logMessagePartRT), +}); + +export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]); export const logEntryRT = rt.type({ id: rt.string, @@ -79,15 +80,20 @@ export const logEntryRT = rt.type({ columns: rt.array(logColumnRT), }); -export type LogMessagepart = rt.TypeOf; +export type LogMessageConstantPart = rt.TypeOf; +export type LogMessageFieldPart = rt.TypeOf; +export type LogMessagePart = rt.TypeOf; +export type LogTimestampColumn = rt.TypeOf; +export type LogFieldColumn = rt.TypeOf; +export type LogMessageColumn = rt.TypeOf; export type LogColumn = rt.TypeOf; export type LogEntry = rt.TypeOf; export const logEntriesResponseRT = rt.type({ data: rt.type({ entries: rt.array(logEntryRT), - topCursor: logEntriesCursorRT, - bottomCursor: logEntriesCursorRT, + topCursor: rt.union([logEntriesCursorRT, rt.null]), + bottomCursor: rt.union([logEntriesCursorRT, rt.null]), }), }); diff --git a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts index 516cd67f2764d..f6d61a7177b49 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts @@ -9,7 +9,7 @@ import { logEntriesBaseRequestRT, logEntriesBeforeRequestRT, logEntriesAfterRequestRT, - logEntriesCenteredRT, + logEntriesCenteredRequestRT, logEntryRT, } from './entries'; import { logEntriesCursorRT } from './common'; @@ -36,7 +36,7 @@ export const logEntriesHighlightsAfterRequestRT = rt.intersection([ ]); export const logEntriesHighlightsCenteredRequestRT = rt.intersection([ - logEntriesCenteredRT, + logEntriesCenteredRequestRT, highlightsRT, ]); diff --git a/x-pack/plugins/infra/common/http_api/log_entries/summary.ts b/x-pack/plugins/infra/common/http_api/log_entries/summary.ts index 4a2c0db0e995e..6af4b7c592ab6 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/summary.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/summary.ts @@ -10,8 +10,8 @@ export const LOG_ENTRIES_SUMMARY_PATH = '/api/log_entries/summary'; export const logEntriesSummaryRequestRT = rt.type({ sourceId: rt.string, - startDate: rt.number, - endDate: rt.number, + startTimestamp: rt.number, + endTimestamp: rt.number, bucketSize: rt.number, query: rt.union([rt.string, rt.undefined, rt.null]), }); diff --git a/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx b/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx new file mode 100644 index 0000000000000..e80f738eac6ba --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface LogDatepickerProps { + startDateExpression: string; + endDateExpression: string; + isStreaming: boolean; + onUpdateDateRange?: (range: { startDateExpression: string; endDateExpression: string }) => void; + onStartStreaming?: () => void; + onStopStreaming?: () => void; +} + +export const LogDatepicker: React.FC = ({ + startDateExpression, + endDateExpression, + isStreaming, + onUpdateDateRange, + onStartStreaming, + onStopStreaming, +}) => { + const handleTimeChange = useCallback( + ({ start, end, isInvalid }) => { + if (onUpdateDateRange && !isInvalid) { + onUpdateDateRange({ startDateExpression: start, endDateExpression: end }); + } + }, + [onUpdateDateRange] + ); + + return ( + + + + + + {isStreaming ? ( + + + + ) : ( + + + + )} + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx index 729689e65739e..2bdb1f91a6dde 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx @@ -10,10 +10,10 @@ import { max } from 'lodash'; import * as React from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { SummaryBucket } from './types'; +import { LogEntriesSummaryBucket } from '../../../../common/http_api'; interface DensityChartProps { - buckets: SummaryBucket[]; + buckets: LogEntriesSummaryBucket[]; end: number; start: number; width: number; @@ -38,36 +38,36 @@ export const DensityChart: React.FC = ({ const xMax = max(buckets.map(bucket => bucket.entriesCount)) || 0; const xScale = scaleLinear() .domain([0, xMax]) - .range([0, width * (2 / 3)]); + .range([0, width]); - const path = area() + const path = area() .x0(xScale(0)) .x1(bucket => xScale(bucket.entriesCount)) - .y(bucket => yScale((bucket.start + bucket.end) / 2)) + .y0(bucket => yScale(bucket.start)) + .y1(bucket => yScale(bucket.end)) .curve(curveMonotoneY); - const pathData = path(buckets); - const highestPathCoord = String(pathData) - .replace(/[^.0-9,]/g, ' ') - .split(/[ ,]/) - .reduce((result, num) => (Number(num) > result ? Number(num) : result), 0); + const firstBucket = buckets[0]; + const lastBucket = buckets[buckets.length - 1]; + const pathBuckets = [ + // Make sure the graph starts at the count of the first point + { start, end: start, entriesCount: firstBucket.entriesCount }, + ...buckets, + // Make sure the line ends at the height of the last point + { start: lastBucket.end, end: lastBucket.end, entriesCount: lastBucket.entriesCount }, + // If the last point is not at the end of the minimap, make sure it doesn't extend indefinitely and goes to 0 + { start: end, end, entriesCount: 0 }, + ]; + const pathData = path(pathBuckets); + return ( - - - + + ); }; -const DensityChartNegativeBackground = euiStyled.rect` - fill: ${props => props.theme.eui.euiColorEmptyShade}; -`; - const DensityChartPositiveBackground = euiStyled.rect` fill: ${props => props.theme.darkMode diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx index 2e45bcea42109..975e83e0075ff 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx @@ -13,6 +13,7 @@ interface HighlightedIntervalProps { getPositionOfTime: (time: number) => number; start: number; end: number; + targetWidth: number; width: number; target: number | null; } @@ -22,6 +23,7 @@ export const HighlightedInterval: React.FC = ({ end, getPositionOfTime, start, + targetWidth, width, target, }) => { @@ -35,14 +37,14 @@ export const HighlightedInterval: React.FC = ({ )} ); diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx index e3a7e5aa30633..c67674d198a3f 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx @@ -13,42 +13,40 @@ import { DensityChart } from './density_chart'; import { HighlightedInterval } from './highlighted_interval'; import { SearchMarkers } from './search_markers'; import { TimeRuler } from './time_ruler'; -import { SummaryBucket, SummaryHighlightBucket } from './types'; +import { + LogEntriesSummaryBucket, + LogEntriesSummaryHighlightsBucket, +} from '../../../../common/http_api'; interface Interval { end: number; start: number; } -interface DragRecord { - startY: number; - currentY: number | null; -} - interface LogMinimapProps { className?: string; height: number; highlightedInterval: Interval | null; jumpToTarget: (params: LogEntryTime) => any; - intervalSize: number; - summaryBuckets: SummaryBucket[]; - summaryHighlightBuckets?: SummaryHighlightBucket[]; + summaryBuckets: LogEntriesSummaryBucket[]; + summaryHighlightBuckets?: LogEntriesSummaryHighlightsBucket[]; target: number | null; + start: number | null; + end: number | null; width: number; } interface LogMinimapState { target: number | null; - drag: DragRecord | null; - svgPosition: ClientRect; timeCursorY: number; } -function calculateYScale(target: number | null, height: number, intervalSize: number) { - const domainStart = target ? target - intervalSize / 2 : 0; - const domainEnd = target ? target + intervalSize / 2 : 0; +// Wide enough to fit "September" +const TIMERULER_WIDTH = 50; + +function calculateYScale(start: number | null, end: number | null, height: number) { return scaleLinear() - .domain([domainStart, domainEnd]) + .domain([start || 0, end || 0]) .range([0, height]); } @@ -58,103 +56,28 @@ export class LogMinimap extends React.Component = event => { + const minimapTop = event.currentTarget.getBoundingClientRect().top; + const clickedYPosition = event.clientY - minimapTop; - public handleClick = (event: MouseEvent) => { - if (!this.dragTargetArea) return; - const svgPosition = this.dragTargetArea.getBoundingClientRect(); - const clickedYPosition = event.clientY - svgPosition.top; const clickedTime = Math.floor(this.getYScale().invert(clickedYPosition)); - this.setState({ - drag: null, - }); - this.props.jumpToTarget({ - tiebreaker: 0, - time: clickedTime, - }); - }; - - private handleMouseDown: React.MouseEventHandler = event => { - const { clientY, target } = event; - if (target === this.dragTargetArea) { - const svgPosition = event.currentTarget.getBoundingClientRect(); - this.setState({ - drag: { - startY: clientY, - currentY: null, - }, - svgPosition, - }); - window.addEventListener('mousemove', this.handleDragMove); - } - window.addEventListener('mouseup', this.handleMouseUp); - }; - - private handleMouseUp = (event: MouseEvent) => { - window.removeEventListener('mousemove', this.handleDragMove); - window.removeEventListener('mouseup', this.handleMouseUp); - const { drag, svgPosition } = this.state; - if (!drag || !drag.currentY) { - this.handleClick(event); - return; - } - const getTime = (pos: number) => Math.floor(this.getYScale().invert(pos)); - const startYPosition = drag.startY - svgPosition.top; - const endYPosition = event.clientY - svgPosition.top; - const startTime = getTime(startYPosition); - const endTime = getTime(endYPosition); - const timeDifference = endTime - startTime; - const newTime = (this.props.target || 0) - timeDifference; - this.setState({ drag: null, target: newTime }); this.props.jumpToTarget({ tiebreaker: 0, - time: newTime, - }); - }; - - private handleDragMove = (event: MouseEvent) => { - const { drag } = this.state; - if (!drag) return; - this.setState({ - drag: { - ...drag, - currentY: event.clientY, - }, + time: clickedTime, }); }; public getYScale = () => { - const { target } = this.state; - const { height, intervalSize } = this.props; - return calculateYScale(target, height, intervalSize); + const { start, end, height } = this.props; + return calculateYScale(start, end, height); }; public getPositionOfTime = (time: number) => { - const { height, intervalSize } = this.props; - - const [minTime] = this.getYScale().domain(); - - return ((time - minTime) * height) / intervalSize; // + return this.getYScale()(time); }; private updateTimeCursor: React.MouseEventHandler = event => { @@ -166,6 +89,8 @@ export class LogMinimap extends React.Component - + + + - - - + {highlightedInterval ? ( ) : null} - - { - this.dragTargetArea = node; - }} - x={0} - y={0} - width={width / 3} - height={height} - /> + ); } } -const DragTargetArea = euiStyled.rect<{ isGrabbing: boolean }>` - fill: transparent; - cursor: ${({ isGrabbing }) => (isGrabbing ? 'grabbing' : 'grab')}; -`; - const MinimapBorder = euiStyled.line` stroke: ${props => props.theme.eui.euiColorMediumShade}; stroke-width: 1px; @@ -269,9 +170,9 @@ const TimeCursor = euiStyled.line` : props.theme.eui.euiColorDarkShade}; `; -const MinimapWrapper = euiStyled.svg<{ showOverscanBoundaries: boolean }>` - background: ${props => - props.showOverscanBoundaries ? props.theme.eui.euiColorMediumShade : 'transparent'}; +const MinimapWrapper = euiStyled.svg` + cursor: pointer; + fill: ${props => props.theme.eui.euiColorEmptyShade}; & ${TimeCursor} { visibility: hidden; } diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx index 8b87aa15f16f0..18d4a3bbfc8b3 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx @@ -10,10 +10,9 @@ import * as React from 'react'; import { euiStyled, keyframes } from '../../../../../observability/public'; import { LogEntryTime } from '../../../../common/log_entry'; import { SearchMarkerTooltip } from './search_marker_tooltip'; -import { SummaryHighlightBucket } from './types'; - +import { LogEntriesSummaryHighlightsBucket } from '../../../../common/http_api'; interface SearchMarkerProps { - bucket: SummaryHighlightBucket; + bucket: LogEntriesSummaryHighlightsBucket; height: number; width: number; jumpToTarget: (target: LogEntryTime) => void; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx index ebdc390aef11b..1e254d999036e 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx @@ -10,10 +10,10 @@ import * as React from 'react'; import { LogEntryTime } from '../../../../common/log_entry'; import { SearchMarker } from './search_marker'; -import { SummaryHighlightBucket } from './types'; +import { LogEntriesSummaryHighlightsBucket } from '../../../../common/http_api'; interface SearchMarkersProps { - buckets: SummaryHighlightBucket[]; + buckets: LogEntriesSummaryHighlightsBucket[]; className?: string; end: number; start: number; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx new file mode 100644 index 0000000000000..af981105d1718 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// The default d3-time-format is a bit strange for small ranges, so we will specify our own +export function getTimeLabelFormat(start: number, end: number): string | undefined { + const diff = Math.abs(end - start); + + // 15 seconds + if (diff < 15 * 1000) { + return ':%S.%L'; + } + + // 16 minutes + if (diff < 16 * 60 * 1000) { + return '%I:%M:%S'; + } + + // Use D3's default + return; +} diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx index b610737663e8d..454935c32fe1e 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx @@ -8,6 +8,7 @@ import { scaleTime } from 'd3-scale'; import * as React from 'react'; import { euiStyled } from '../../../../../observability/public'; +import { getTimeLabelFormat } from './time_label_formatter'; interface TimeRulerProps { end: number; @@ -23,37 +24,19 @@ export const TimeRuler: React.FC = ({ end, height, start, tickCo .range([0, height]); const ticks = yScale.ticks(tickCount); - const formatTick = yScale.tickFormat(); - - const dateModLabel = (() => { - for (let i = 0; i < ticks.length; i++) { - const tickLabel = formatTick(ticks[i]); - if (!tickLabel[0].match(/[0-9]/)) { - return i % 12; - } - } - })(); + const formatTick = yScale.tickFormat(tickCount, getTimeLabelFormat(start, end)); return ( {ticks.map((tick, tickIndex) => { const y = yScale(tick); - const isLabeledTick = tickIndex % 12 === dateModLabel; - const tickStartX = isLabeledTick ? 0 : width / 3 - 4; + return ( - {isLabeledTick && ( - - {formatTick(tick)} - - )} - + + {formatTick(tick)} + + ); })} @@ -71,15 +54,11 @@ const TimeRulerTickLabel = euiStyled.text` pointer-events: none; `; -const TimeRulerGridLine = euiStyled.line<{ isDark: boolean }>` +const TimeRulerGridLine = euiStyled.line` stroke: ${props => - props.isDark - ? props.theme.darkMode - ? props.theme.eui.euiColorDarkestShade - : props.theme.eui.euiColorDarkShade - : props.theme.darkMode - ? props.theme.eui.euiColorDarkShade - : props.theme.eui.euiColorMediumShade}; + props.theme.darkMode + ? props.theme.eui.euiColorDarkestShade + : props.theme.eui.euiColorDarkShade}; stroke-opacity: 0.5; stroke-width: 1px; `; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts b/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts deleted file mode 100644 index d8197935dafa7..0000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts +++ /dev/null @@ -1,17 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { TimeKey } from '../../../../common/time'; - -export interface SummaryBucket { - start: number; - end: number; - entriesCount: number; -} - -export interface SummaryHighlightBucket extends SummaryBucket { - representativeKey: TimeKey; -} diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx deleted file mode 100644 index 41c6e554e603a..0000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx +++ /dev/null @@ -1,67 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFormRow, EuiRadioGroup } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import * as React from 'react'; - -interface IntervalSizeDescriptor { - label: string; - intervalSize: number; -} - -interface LogMinimapScaleControlsProps { - availableIntervalSizes: IntervalSizeDescriptor[]; - intervalSize: number; - setIntervalSize: (intervalSize: number) => any; -} - -export class LogMinimapScaleControls extends React.PureComponent { - public handleScaleChange = (intervalSizeDescriptorKey: string) => { - const { availableIntervalSizes, setIntervalSize } = this.props; - const [sizeDescriptor] = availableIntervalSizes.filter( - intervalKeyEquals(intervalSizeDescriptorKey) - ); - - if (sizeDescriptor) { - setIntervalSize(sizeDescriptor.intervalSize); - } - }; - - public render() { - const { availableIntervalSizes, intervalSize } = this.props; - const [currentSizeDescriptor] = availableIntervalSizes.filter(intervalSizeEquals(intervalSize)); - - return ( - - } - > - ({ - id: getIntervalSizeDescriptorKey(sizeDescriptor), - label: sizeDescriptor.label, - }))} - onChange={this.handleScaleChange} - idSelected={getIntervalSizeDescriptorKey(currentSizeDescriptor)} - /> - - ); - } -} - -const getIntervalSizeDescriptorKey = (sizeDescriptor: IntervalSizeDescriptor) => - `${sizeDescriptor.intervalSize}`; - -const intervalKeyEquals = (key: string) => (sizeDescriptor: IntervalSizeDescriptor) => - getIntervalSizeDescriptorKey(sizeDescriptor) === key; - -const intervalSizeEquals = (size: number) => (sizeDescriptor: IntervalSizeDescriptor) => - sizeDescriptor.intervalSize === size; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts index ca5ca9736b7b3..19e8108ee50e8 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts @@ -7,27 +7,27 @@ import { bisector } from 'd3-array'; import { compareToTimeKey, TimeKey } from '../../../../common/time'; -import { LogEntry, LogEntryHighlight } from '../../../utils/log_entry'; +import { LogEntry } from '../../../../common/http_api'; export type StreamItem = LogEntryStreamItem; export interface LogEntryStreamItem { kind: 'logEntry'; logEntry: LogEntry; - highlights: LogEntryHighlight[]; + highlights: LogEntry[]; } export function getStreamItemTimeKey(item: StreamItem) { switch (item.kind) { case 'logEntry': - return item.logEntry.key; + return item.logEntry.cursor; } } export function getStreamItemId(item: StreamItem) { switch (item.kind) { case 'logEntry': - return `${item.logEntry.key.time}:${item.logEntry.key.tiebreaker}:${item.logEntry.gid}`; + return `${item.logEntry.cursor.time}:${item.logEntry.cursor.tiebreaker}:${item.logEntry.id}`; } } diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx index 8c48d9e176d3b..5598528c0e0f5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx @@ -6,144 +6,279 @@ /* eslint-disable max-classes-per-file */ -import { EuiButtonEmpty, EuiIcon, EuiProgress, EuiText } from '@elastic/eui'; -import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiLoadingSpinner, + EuiButton, +} from '@elastic/eui'; +import { FormattedMessage, FormattedTime, FormattedRelative } from '@kbn/i18n/react'; import * as React from 'react'; +import { Unit } from '@elastic/datemath'; import { euiStyled } from '../../../../../observability/public'; +import { LogTextSeparator } from './log_text_separator'; +import { extendDatemath } from '../../../utils/datemath'; + +type Position = 'start' | 'end'; interface LogTextStreamLoadingItemViewProps { - alignment: 'top' | 'bottom'; + position: Position; + timestamp: number; // Either the top of the bottom's cursor timestamp + startDateExpression: string; + endDateExpression: string; className?: string; hasMore: boolean; isLoading: boolean; isStreaming: boolean; - lastStreamingUpdate: Date | null; - onLoadMore?: () => void; + onExtendRange?: (newDate: string) => void; + onStreamStart?: () => void; } +const TIMESTAMP_FORMAT = { + hour12: false, + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', +}; + export class LogTextStreamLoadingItemView extends React.PureComponent< LogTextStreamLoadingItemViewProps, {} > { public render() { const { - alignment, + position, + timestamp, + startDateExpression, + endDateExpression, className, hasMore, isLoading, isStreaming, - lastStreamingUpdate, - onLoadMore, + onExtendRange, + onStreamStart, } = this.props; - if (isStreaming) { - return ( - - - - - - - {lastStreamingUpdate ? ( - - - - - ), - }} - /> - - - ) : null} - - ); - } else if (isLoading) { - return ( - - - - - - ); - } else if (!hasMore) { - return ( - - - - - {onLoadMore ? ( - - - - ) : null} - - ); - } else { - return null; - } + const shouldShowCta = !hasMore && !isStreaming; + + const extra = ( + + {isLoading || isStreaming ? ( + + ) : shouldShowCta ? ( + + ) : null} + + ); + + return ( + + {position === 'start' ? extra : null} + + {position === 'end' ? extra : null} + + ); } } -interface ProgressEntryProps { - alignment: 'top' | 'bottom'; - className?: string; - color: 'subdued' | 'primary'; - isLoading: boolean; -} +const LoadingItemViewExtra = euiStyled(EuiFlexGroup)` + height: 40px; +`; -const ProgressEntry: React.FC = props => { - const { alignment, children, className, color, isLoading } = props; +const ProgressEntryWrapper = euiStyled.div<{ position: Position }>` + padding-left: ${props => props.theme.eui.euiSizeS}; + padding-top: ${props => + props.position === 'start' ? props.theme.eui.euiSizeL : props.theme.eui.euiSizeM}; + padding-bottom: ${props => + props.position === 'end' ? props.theme.eui.euiSizeL : props.theme.eui.euiSizeM}; +`; - // NOTE: styled-components seems to make all props in EuiProgress required, so this - // style attribute hacking replaces styled-components here for now until that can be fixed - // see: https://github.com/elastic/eui/issues/1655 - const alignmentStyle = - alignment === 'top' ? { top: 0, bottom: 'initial' } : { top: 'initial', bottom: 0 }; +type ProgressMessageProps = Pick< + LogTextStreamLoadingItemViewProps, + 'timestamp' | 'position' | 'isStreaming' +>; +const ProgressMessage: React.FC = ({ timestamp, position, isStreaming }) => { + const formattedTimestamp = + isStreaming && position === 'end' ? ( + + ) : ( + + ); - return ( - - + ) : isStreaming ? ( + + ) : ( + - {children} - + ); + + return ( + + {message} + ); }; -const ProgressEntryWrapper = euiStyled.div` - align-items: center; - display: flex; - min-height: ${props => props.theme.eui.euiSizeXXL}; - position: relative; -`; +const ProgressSpinner: React.FC<{ kind: 'streaming' | 'loading' }> = ({ kind }) => ( + <> + + + + + + {kind === 'streaming' ? ( + + ) : ( + + )} + + + +); -const ProgressMessage = euiStyled.div` - padding: 8px 16px; -`; +type ProgressCtaProps = Pick< + LogTextStreamLoadingItemViewProps, + 'position' | 'startDateExpression' | 'endDateExpression' | 'onExtendRange' | 'onStreamStart' +>; +const ProgressCta: React.FC = ({ + position, + startDateExpression, + endDateExpression, + onExtendRange, + onStreamStart, +}) => { + const rangeEdge = position === 'start' ? startDateExpression : endDateExpression; + + if (rangeEdge === 'now' && position === 'end') { + return ( + + + + ); + } + + const iconType = position === 'start' ? 'arrowUp' : 'arrowDown'; + const extendedRange = + position === 'start' + ? extendDatemath(startDateExpression, 'before', endDateExpression) + : extendDatemath(endDateExpression, 'after', startDateExpression); + if (!extendedRange || !('diffUnit' in extendedRange)) { + return null; + } + + return ( + { + if (typeof onExtendRange === 'function') { + onExtendRange(extendedRange.value); + } + }} + iconType={iconType} + size="s" + > + + + ); +}; + +const ProgressExtendMessage: React.FC<{ amount: number; unit: Unit }> = ({ amount, unit }) => { + switch (unit) { + case 'ms': + return ( + + ); + case 's': + return ( + + ); + case 'm': + return ( + + ); + case 'h': + return ( + + ); + case 'd': + return ( + + ); + case 'w': + return ( + + ); + case 'M': + return ( + + ); + case 'y': + return ( + + ); + default: + throw new TypeError('Unhandled unit: ' + unit); + } +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx index 5d295ca7e4817..5fc4606a774d5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx @@ -8,15 +8,16 @@ import { mount } from 'enzyme'; import React from 'react'; import { EuiThemeProvider } from '../../../../../observability/public'; -import { LogEntryColumn } from '../../../utils/log_entry'; import { LogEntryFieldColumn } from './log_entry_field_column'; +import { LogColumn } from '../../../../common/http_api'; describe('LogEntryFieldColumn', () => { it('should output a
    when displaying an Array of values', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify(['a', 'b', 'c']), + value: ['a', 'b', 'c'], + highlights: [], }; const component = mount( @@ -42,13 +43,14 @@ describe('LogEntryFieldColumn', () => { }); it('should output a text representation of a passed complex value', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify({ + value: { lat: 1, lon: 2, - }), + }, + highlights: [], }; const component = mount( @@ -67,10 +69,11 @@ describe('LogEntryFieldColumn', () => { }); it('should output just text when passed a non-Array', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify('foo'), + value: 'foo', + highlights: [], }; const component = mount( diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx index c6584f2fdbb6d..202108cda5ac0 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx @@ -8,14 +8,10 @@ import stringify from 'json-stable-stringify'; import React, { useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { - isFieldColumn, - isHighlightFieldColumn, - LogEntryColumn, - LogEntryHighlightColumn, -} from '../../../utils/log_entry'; +import { isFieldColumn, isHighlightFieldColumn } from '../../../utils/log_entry'; import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting'; import { LogEntryColumnContent } from './log_entry_column'; +import { LogColumn } from '../../../../common/http_api'; import { hoveredContentStyle, longWrappedContentStyle, @@ -25,8 +21,8 @@ import { } from './text_styles'; interface LogEntryFieldColumnProps { - columnValue: LogEntryColumn; - highlights: LogEntryHighlightColumn[]; + columnValue: LogColumn; + highlights: LogColumn[]; isActiveHighlight: boolean; isHighlighted: boolean; isHovered: boolean; @@ -41,9 +37,12 @@ export const LogEntryFieldColumn: React.FunctionComponent { - const value = useMemo(() => (isFieldColumn(columnValue) ? JSON.parse(columnValue.value) : null), [ - columnValue, - ]); + const value = useMemo(() => { + if (isFieldColumn(columnValue)) { + return columnValue.value; + } + return null; + }, [columnValue]); const formattedValue = Array.isArray(value) ? (
      {value.map((entry, i) => ( @@ -58,7 +57,7 @@ export const LogEntryFieldColumn: React.FunctionComponent ) : ( highlightFieldValue( - typeof value === 'object' && value != null ? stringify(value) : value, + typeof value === 'string' ? value : stringify(value), isHighlightFieldColumn(firstHighlight) ? firstHighlight.highlights : [], isActiveHighlight ? ActiveHighlightMarker : HighlightMarker ) diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx index 122f0fe472c6e..5ad7cba6427d1 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx @@ -5,6 +5,7 @@ */ import React, { memo, useMemo } from 'react'; +import stringify from 'json-stable-stringify'; import { euiStyled } from '../../../../../observability/public'; import { @@ -12,9 +13,7 @@ import { isFieldSegment, isHighlightMessageColumn, isMessageColumn, - LogEntryColumn, - LogEntryHighlightColumn, - LogEntryMessageSegment, + isHighlightFieldSegment, } from '../../../utils/log_entry'; import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting'; import { LogEntryColumnContent } from './log_entry_column'; @@ -25,10 +24,11 @@ import { unwrappedContentStyle, WrapMode, } from './text_styles'; +import { LogColumn, LogMessagePart } from '../../../../common/http_api'; interface LogEntryMessageColumnProps { - columnValue: LogEntryColumn; - highlights: LogEntryHighlightColumn[]; + columnValue: LogColumn; + highlights: LogColumn[]; isActiveHighlight: boolean; isHighlighted: boolean; isHovered: boolean; @@ -72,28 +72,39 @@ const MessageColumnContent = euiStyled(LogEntryColumnContent) messageSegments.map((messageSegment, index) => formatMessageSegment( messageSegment, - highlights.map(highlight => - isHighlightMessageColumn(highlight) ? highlight.message[index].highlights : [] - ), + highlights.map(highlight => { + if (isHighlightMessageColumn(highlight)) { + const segment = highlight.message[index]; + if (isHighlightFieldSegment(segment)) { + return segment.highlights; + } + } + return []; + }), isActiveHighlight ) ); const formatMessageSegment = ( - messageSegment: LogEntryMessageSegment, + messageSegment: LogMessagePart, [firstHighlight = []]: string[][], // we only support one highlight for now isActiveHighlight: boolean ): React.ReactNode => { if (isFieldSegment(messageSegment)) { + const value = + typeof messageSegment.value === 'string' + ? messageSegment.value + : stringify(messageSegment.value); + return highlightFieldValue( - messageSegment.value, + value, firstHighlight, isActiveHighlight ? ActiveHighlightMarker : HighlightMarker ); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index e5e3740f420e8..ce264245d385b 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -7,12 +7,7 @@ import React, { memo, useState, useCallback, useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { - LogEntry, - LogEntryHighlight, - LogEntryHighlightColumn, - isTimestampColumn, -} from '../../../utils/log_entry'; +import { isTimestampColumn } from '../../../utils/log_entry'; import { LogColumnConfiguration, isTimestampLogColumnConfiguration, @@ -26,12 +21,13 @@ import { LogEntryDetailsIconColumn } from './log_entry_icon_column'; import { LogEntryMessageColumn } from './log_entry_message_column'; import { LogEntryTimestampColumn } from './log_entry_timestamp_column'; import { monospaceTextStyle } from './text_styles'; +import { LogEntry, LogColumn } from '../../../../common/http_api'; interface LogEntryRowProps { boundingBoxRef?: React.Ref; columnConfigurations: LogColumnConfiguration[]; columnWidths: LogEntryColumnWidths; - highlights: LogEntryHighlight[]; + highlights: LogEntry[]; isActiveHighlight: boolean; isHighlighted: boolean; logEntry: LogEntry; @@ -63,9 +59,9 @@ export const LogEntryRow = memo( setIsHovered(false); }, []); - const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.gid), [ + const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.id), [ openFlyoutWithItem, - logEntry.gid, + logEntry.id, ]); const logEntryColumnsById = useMemo( @@ -85,7 +81,7 @@ export const LogEntryRow = memo( const highlightsByColumnId = useMemo( () => highlights.reduce<{ - [columnId: string]: LogEntryHighlightColumn[]; + [columnId: string]: LogColumn[]; }>( (columnsById, highlight) => highlight.columns.reduce( diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx new file mode 100644 index 0000000000000..9cc91fa11e4ed --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; + +/** + * Create a separator with a text on the right side + */ +export const LogTextSeparator: React.FC = ({ children }) => { + return ( + + {children} + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index 6544a32ba414c..2c389b47fa6cf 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -54,6 +54,10 @@ interface ScrollableLogTextStreamViewProps { setFlyoutVisibility: (visible: boolean) => void; highlightedItem: string | null; currentHighlightKey: UniqueTimeKey | null; + startDateExpression: string; + endDateExpression: string; + updateDateRange: (range: { startDateExpression?: string; endDateExpression?: string }) => void; + startLiveStreaming: () => void; } interface ScrollableLogTextStreamViewState { @@ -90,7 +94,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent< targetId: getStreamItemId(getStreamItemBeforeTimeKey(nextProps.items, nextProps.target!)), items: nextItems, }; - } else if (!nextProps.target || !hasItems) { + } else if (!hasItems) { return { target: null, targetId: null, @@ -129,9 +133,13 @@ export class ScrollableLogTextStreamView extends React.PureComponent< isLoadingMore, isReloading, isStreaming, - lastLoadedTime, scale, wrap, + startDateExpression, + endDateExpression, + lastLoadedTime, + updateDateRange, + startLiveStreaming, } = this.props; const { targetId, items, isScrollLocked } = this.state; const hasItems = items.length > 0; @@ -184,72 +192,88 @@ export class ScrollableLogTextStreamView extends React.PureComponent< isLocked={isScrollLocked} entriesCount={items.length} > - {registerChild => ( - <> - - {items.map((item, idx) => { - const currentTimestamp = item.logEntry.key.time; - let showDate = false; + {registerChild => + items.length > 0 ? ( + <> + + updateDateRange({ startDateExpression: newDateExpression }) + } + /> + {items.map((item, idx) => { + const currentTimestamp = item.logEntry.cursor.time; + let showDate = false; - if (idx > 0) { - const prevTimestamp = items[idx - 1].logEntry.key.time; - showDate = !moment(currentTimestamp).isSame(prevTimestamp, 'day'); - } + if (idx > 0) { + const prevTimestamp = items[idx - 1].logEntry.cursor.time; + showDate = !moment(currentTimestamp).isSame(prevTimestamp, 'day'); + } - return ( - - {showDate && } - - {itemMeasureRef => ( - - )} - - - ); - })} - - {isScrollLocked && ( - + {showDate && } + + {itemMeasureRef => ( + + )} + + + ); + })} + + updateDateRange({ endDateExpression: newDateExpression }) + } + onStreamStart={() => startLiveStreaming()} /> - )} - - )} + {isScrollLocked && ( + + )} + + ) : null + } )} @@ -275,14 +299,6 @@ export class ScrollableLogTextStreamView extends React.PureComponent< } }; - private handleLoadNewerItems = () => { - const { loadNewerItems } = this.props; - - if (loadNewerItems) { - loadNewerItems(); - } - }; - // this is actually a method but not recognized as such // eslint-disable-next-line @typescript-eslint/member-ordering private handleVisibleChildrenChange = callWithoutRepeats( diff --git a/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx deleted file mode 100644 index 3653a6d6bbeae..0000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx +++ /dev/null @@ -1,97 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiDatePicker, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import moment, { Moment } from 'moment'; -import React from 'react'; -import { FixedDatePicker } from '../fixed_datepicker'; - -const noop = () => undefined; - -interface LogTimeControlsProps { - currentTime: number | null; - startLiveStreaming: () => any; - stopLiveStreaming: () => void; - isLiveStreaming: boolean; - jumpToTime: (time: number) => any; -} - -export class LogTimeControls extends React.PureComponent { - public render() { - const { currentTime, isLiveStreaming } = this.props; - - const currentMoment = currentTime ? moment(currentTime) : null; - if (isLiveStreaming) { - return ( - - - - - - - - - - - ); - } else { - return ( - - - - - - - - - - - ); - } - } - - private handleChangeDate = (date: Moment | null) => { - if (date !== null) { - this.props.jumpToTime(date.valueOf()); - } - }; - - private startLiveStreaming = () => { - this.props.startLiveStreaming(); - }; - - private stopLiveStreaming = () => { - this.props.stopLiveStreaming(); - }; -} diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts new file mode 100644 index 0000000000000..2a19a82892427 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; + +import { + LOG_ENTRIES_PATH, + LogEntriesRequest, + logEntriesRequestRT, + logEntriesResponseRT, +} from '../../../../../common/http_api'; + +export const fetchLogEntries = async (requestArgs: LogEntriesRequest) => { + const response = await npStart.http.fetch(LOG_ENTRIES_PATH, { + method: 'POST', + body: JSON.stringify(logEntriesRequestRT.encode(requestArgs)), + }); + + return pipe(logEntriesResponseRT.decode(response), fold(throwErrors(createPlainError), identity)); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts deleted file mode 100644 index 83bae37c348d4..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { ApolloClient } from 'apollo-client'; -import { TimeKey } from '../../../../common/time'; -import { logEntriesQuery } from '../../../graphql/log_entries.gql_query'; -import { useApolloClient } from '../../../utils/apollo_context'; -import { LogEntriesResponse } from '.'; - -const LOAD_CHUNK_SIZE = 200; - -type LogEntriesGetter = ( - client: ApolloClient<{}>, - countBefore: number, - countAfter: number -) => (params: { - sourceId: string; - timeKey: TimeKey | null; - filterQuery: string | null; -}) => Promise; - -const getLogEntries: LogEntriesGetter = (client, countBefore, countAfter) => async ({ - sourceId, - timeKey, - filterQuery, -}) => { - if (!timeKey) throw new Error('TimeKey is null'); - const result = await client.query({ - query: logEntriesQuery, - variables: { - sourceId, - timeKey: { time: timeKey.time, tiebreaker: timeKey.tiebreaker }, - countBefore, - countAfter, - filterQuery, - }, - fetchPolicy: 'no-cache', - }); - // Workaround for Typescript. Since we're removing the GraphQL API in another PR or two - // 7.6 goes out I don't think it's worth the effort to actually make this - // typecheck pass - const { source } = result.data as any; - const { logEntriesAround } = source; - return { - entries: logEntriesAround.entries, - entriesStart: logEntriesAround.start, - entriesEnd: logEntriesAround.end, - hasMoreAfterEnd: logEntriesAround.hasMoreAfter, - hasMoreBeforeStart: logEntriesAround.hasMoreBefore, - lastLoadedTime: new Date(), - }; -}; - -export const useGraphQLQueries = () => { - const client = useApolloClient(); - if (!client) throw new Error('Unable to get Apollo Client from context'); - return { - getLogEntriesAround: getLogEntries(client, LOAD_CHUNK_SIZE, LOAD_CHUNK_SIZE), - getLogEntriesBefore: getLogEntries(client, LOAD_CHUNK_SIZE, 0), - getLogEntriesAfter: getLogEntries(client, 0, LOAD_CHUNK_SIZE), - }; -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts index 04412f5fdd871..b9a5c4068e166 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts @@ -5,12 +5,18 @@ */ import { useEffect, useState, useReducer, useCallback } from 'react'; import createContainer from 'constate'; -import { pick, throttle, omit } from 'lodash'; -import { useGraphQLQueries } from './gql_queries'; +import { pick, throttle } from 'lodash'; import { TimeKey, timeKeyIsBetween } from '../../../../common/time'; -import { InfraLogEntry } from './types'; +import { + LogEntriesResponse, + LogEntry, + LogEntriesRequest, + LogEntriesBaseRequest, +} from '../../../../common/http_api'; +import { fetchLogEntries } from './api/fetch_log_entries'; const DESIRED_BUFFER_PAGES = 2; +const LIVE_STREAM_INTERVAL = 5000; enum Action { FetchingNewEntries, @@ -20,6 +26,7 @@ enum Action { ReceiveEntriesAfter, ErrorOnNewEntries, ErrorOnMoreEntries, + ExpandRange, } type ReceiveActions = @@ -29,41 +36,46 @@ type ReceiveActions = interface ReceiveEntriesAction { type: ReceiveActions; - payload: LogEntriesResponse; + payload: LogEntriesResponse['data']; +} +interface ExpandRangeAction { + type: Action.ExpandRange; + payload: { before: boolean; after: boolean }; } interface FetchOrErrorAction { - type: Exclude; + type: Exclude; } -type ActionObj = ReceiveEntriesAction | FetchOrErrorAction; +type ActionObj = ReceiveEntriesAction | FetchOrErrorAction | ExpandRangeAction; type Dispatch = (action: ActionObj) => void; interface LogEntriesProps { + startTimestamp: number; + endTimestamp: number; + timestampsLastUpdate: number; filterQuery: string | null; timeKey: TimeKey | null; pagesBeforeStart: number | null; pagesAfterEnd: number | null; sourceId: string; - isAutoReloading: boolean; + isStreaming: boolean; jumpToTargetPosition: (position: TimeKey) => void; } -type FetchEntriesParams = Omit; +type FetchEntriesParams = Omit; type FetchMoreEntriesParams = Pick; -export interface LogEntriesResponse { - entries: InfraLogEntry[]; - entriesStart: TimeKey | null; - entriesEnd: TimeKey | null; - hasMoreAfterEnd: boolean; - hasMoreBeforeStart: boolean; - lastLoadedTime: Date | null; -} - -export type LogEntriesStateParams = { +export interface LogEntriesStateParams { + entries: LogEntriesResponse['data']['entries']; + topCursor: LogEntriesResponse['data']['topCursor'] | null; + bottomCursor: LogEntriesResponse['data']['bottomCursor'] | null; + centerCursor: TimeKey | null; isReloading: boolean; isLoadingMore: boolean; -} & LogEntriesResponse; + lastLoadedTime: Date | null; + hasMoreBeforeStart: boolean; + hasMoreAfterEnd: boolean; +} export interface LogEntriesCallbacks { fetchNewerEntries: () => Promise; @@ -75,32 +87,40 @@ export const logEntriesInitialCallbacks = { export const logEntriesInitialState: LogEntriesStateParams = { entries: [], - entriesStart: null, - entriesEnd: null, - hasMoreAfterEnd: false, - hasMoreBeforeStart: false, + topCursor: null, + bottomCursor: null, + centerCursor: null, isReloading: true, isLoadingMore: false, lastLoadedTime: null, + hasMoreBeforeStart: false, + hasMoreAfterEnd: false, }; -const cleanDuplicateItems = (entriesA: InfraLogEntry[], entriesB: InfraLogEntry[]) => { - const gids = new Set(entriesB.map(item => item.gid)); - return entriesA.filter(item => !gids.has(item.gid)); +const cleanDuplicateItems = (entriesA: LogEntry[], entriesB: LogEntry[]) => { + const ids = new Set(entriesB.map(item => item.id)); + return entriesA.filter(item => !ids.has(item.id)); }; const shouldFetchNewEntries = ({ prevParams, timeKey, filterQuery, - entriesStart, - entriesEnd, -}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams }) => { - if (!timeKey) return false; - const shouldLoadWithNewFilter = filterQuery !== prevParams.filterQuery; + topCursor, + bottomCursor, + startTimestamp, + endTimestamp, +}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams | undefined }) => { + const shouldLoadWithNewDates = prevParams + ? (startTimestamp !== prevParams.startTimestamp && + startTimestamp > prevParams.startTimestamp) || + (endTimestamp !== prevParams.endTimestamp && endTimestamp < prevParams.endTimestamp) + : true; + const shouldLoadWithNewFilter = prevParams ? filterQuery !== prevParams.filterQuery : true; const shouldLoadAroundNewPosition = - !entriesStart || !entriesEnd || !timeKeyIsBetween(entriesStart, entriesEnd, timeKey); - return shouldLoadWithNewFilter || shouldLoadAroundNewPosition; + timeKey && (!topCursor || !bottomCursor || !timeKeyIsBetween(topCursor, bottomCursor, timeKey)); + + return shouldLoadWithNewDates || shouldLoadWithNewFilter || shouldLoadAroundNewPosition; }; enum ShouldFetchMoreEntries { @@ -124,48 +144,105 @@ const useFetchEntriesEffect = ( dispatch: Dispatch, props: LogEntriesProps ) => { - const { getLogEntriesAround, getLogEntriesBefore, getLogEntriesAfter } = useGraphQLQueries(); - - const [prevParams, cachePrevParams] = useState(props); + const [prevParams, cachePrevParams] = useState(); const [startedStreaming, setStartedStreaming] = useState(false); - const runFetchNewEntriesRequest = async (override = {}) => { + const runFetchNewEntriesRequest = async (overrides: Partial = {}) => { + if (!props.startTimestamp || !props.endTimestamp) { + return; + } + dispatch({ type: Action.FetchingNewEntries }); + try { - const payload = await getLogEntriesAround({ - ...omit(props, 'jumpToTargetPosition'), - ...override, - }); + const commonFetchArgs: LogEntriesBaseRequest = { + sourceId: overrides.sourceId || props.sourceId, + startTimestamp: overrides.startTimestamp || props.startTimestamp, + endTimestamp: overrides.endTimestamp || props.endTimestamp, + query: overrides.filterQuery || props.filterQuery, + }; + + const fetchArgs: LogEntriesRequest = props.timeKey + ? { + ...commonFetchArgs, + center: props.timeKey, + } + : { + ...commonFetchArgs, + before: 'last', + }; + + const { data: payload } = await fetchLogEntries(fetchArgs); dispatch({ type: Action.ReceiveNewEntries, payload }); + + // Move position to the bottom if it's the first load. + // Do it in the next tick to allow the `dispatch` to fire + if (!props.timeKey && payload.bottomCursor) { + setTimeout(() => { + props.jumpToTargetPosition(payload.bottomCursor!); + }); + } else if ( + props.timeKey && + payload.topCursor && + payload.bottomCursor && + !timeKeyIsBetween(payload.topCursor, payload.bottomCursor, props.timeKey) + ) { + props.jumpToTargetPosition(payload.topCursor); + } } catch (e) { dispatch({ type: Action.ErrorOnNewEntries }); } }; const runFetchMoreEntriesRequest = async (direction: ShouldFetchMoreEntries) => { - dispatch({ type: Action.FetchingMoreEntries }); + if (!props.startTimestamp || !props.endTimestamp) { + return; + } const getEntriesBefore = direction === ShouldFetchMoreEntries.Before; - const timeKey = getEntriesBefore - ? state.entries[0].key - : state.entries[state.entries.length - 1].key; - const getMoreLogEntries = getEntriesBefore ? getLogEntriesBefore : getLogEntriesAfter; + + // Control that cursors are correct + if ((getEntriesBefore && !state.topCursor) || !state.bottomCursor) { + return; + } + + dispatch({ type: Action.FetchingMoreEntries }); + try { - const payload = await getMoreLogEntries({ ...props, timeKey }); + const commonFetchArgs: LogEntriesBaseRequest = { + sourceId: props.sourceId, + startTimestamp: props.startTimestamp, + endTimestamp: props.endTimestamp, + query: props.filterQuery, + }; + + const fetchArgs: LogEntriesRequest = getEntriesBefore + ? { + ...commonFetchArgs, + before: state.topCursor!, // We already check for nullity above + } + : { + ...commonFetchArgs, + after: state.bottomCursor, + }; + + const { data: payload } = await fetchLogEntries(fetchArgs); + dispatch({ type: getEntriesBefore ? Action.ReceiveEntriesBefore : Action.ReceiveEntriesAfter, payload, }); - return payload.entriesEnd; + + return payload.bottomCursor; } catch (e) { dispatch({ type: Action.ErrorOnMoreEntries }); } }; const fetchNewEntriesEffectDependencies = Object.values( - pick(props, ['sourceId', 'filterQuery', 'timeKey']) + pick(props, ['sourceId', 'filterQuery', 'timeKey', 'startTimestamp', 'endTimestamp']) ); const fetchNewEntriesEffect = () => { - if (props.isAutoReloading) return; + if (props.isStreaming && prevParams) return; if (shouldFetchNewEntries({ ...props, ...state, prevParams })) { runFetchNewEntriesRequest(); } @@ -177,7 +254,7 @@ const useFetchEntriesEffect = ( Object.values(pick(state, ['hasMoreBeforeStart', 'hasMoreAfterEnd'])), ]; const fetchMoreEntriesEffect = () => { - if (state.isLoadingMore || props.isAutoReloading) return; + if (state.isLoadingMore || props.isStreaming) return; const direction = shouldFetchMoreEntries(props, state); switch (direction) { case ShouldFetchMoreEntries.Before: @@ -191,30 +268,25 @@ const useFetchEntriesEffect = ( const fetchNewerEntries = useCallback( throttle(() => runFetchMoreEntriesRequest(ShouldFetchMoreEntries.After), 500), - [props, state.entriesEnd] + [props, state.bottomCursor] ); const streamEntriesEffectDependencies = [ - props.isAutoReloading, + props.isStreaming, state.isLoadingMore, state.isReloading, ]; const streamEntriesEffect = () => { (async () => { - if (props.isAutoReloading && !state.isLoadingMore && !state.isReloading) { + if (props.isStreaming && !state.isLoadingMore && !state.isReloading) { if (startedStreaming) { - await new Promise(res => setTimeout(res, 5000)); + await new Promise(res => setTimeout(res, LIVE_STREAM_INTERVAL)); } else { - const nowKey = { - tiebreaker: 0, - time: Date.now(), - }; - props.jumpToTargetPosition(nowKey); + const endTimestamp = Date.now(); + props.jumpToTargetPosition({ tiebreaker: 0, time: endTimestamp }); setStartedStreaming(true); if (state.hasMoreAfterEnd) { - runFetchNewEntriesRequest({ - timeKey: nowKey, - }); + runFetchNewEntriesRequest({ endTimestamp }); return; } } @@ -222,15 +294,41 @@ const useFetchEntriesEffect = ( if (newEntriesEnd) { props.jumpToTargetPosition(newEntriesEnd); } - } else if (!props.isAutoReloading) { + } else if (!props.isStreaming) { setStartedStreaming(false); } })(); }; + const expandRangeEffect = () => { + if (!prevParams || !prevParams.startTimestamp || !prevParams.endTimestamp) { + return; + } + + if (props.timestampsLastUpdate === prevParams.timestampsLastUpdate) { + return; + } + + const shouldExpand = { + before: props.startTimestamp < prevParams.startTimestamp, + after: props.endTimestamp > prevParams.endTimestamp, + }; + + dispatch({ type: Action.ExpandRange, payload: shouldExpand }); + }; + + const expandRangeEffectDependencies = [ + prevParams?.startTimestamp, + prevParams?.endTimestamp, + props.startTimestamp, + props.endTimestamp, + props.timestampsLastUpdate, + ]; + useEffect(fetchNewEntriesEffect, fetchNewEntriesEffectDependencies); useEffect(fetchMoreEntriesEffect, fetchMoreEntriesEffectDependencies); useEffect(streamEntriesEffect, streamEntriesEffectDependencies); + useEffect(expandRangeEffect, expandRangeEffectDependencies); return { fetchNewerEntries, checkForNewEntries: runFetchNewEntriesRequest }; }; @@ -249,44 +347,87 @@ export const useLogEntriesState: ( const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: ActionObj) => { switch (action.type) { case Action.ReceiveNewEntries: - return { ...prevState, ...action.payload, isReloading: false }; + return { + ...prevState, + ...action.payload, + centerCursor: getCenterCursor(action.payload.entries), + lastLoadedTime: new Date(), + isReloading: false, + + // Be optimistic. If any of the before/after requests comes empty, set + // the corresponding flag to `false` + hasMoreBeforeStart: true, + hasMoreAfterEnd: true, + }; case Action.ReceiveEntriesBefore: { - const prevEntries = cleanDuplicateItems(prevState.entries, action.payload.entries); - const newEntries = [...action.payload.entries, ...prevEntries]; - const { hasMoreBeforeStart, entriesStart, lastLoadedTime } = action.payload; + const newEntries = action.payload.entries; + const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); + const entries = [...newEntries, ...prevEntries]; + const update = { - entries: newEntries, + entries, isLoadingMore: false, - hasMoreBeforeStart, - entriesStart, - lastLoadedTime, + hasMoreBeforeStart: newEntries.length > 0, + // Keep the previous cursor if request comes empty, to easily extend the range. + topCursor: newEntries.length > 0 ? action.payload.topCursor : prevState.topCursor, + centerCursor: getCenterCursor(entries), + lastLoadedTime: new Date(), }; + return { ...prevState, ...update }; } case Action.ReceiveEntriesAfter: { - const prevEntries = cleanDuplicateItems(prevState.entries, action.payload.entries); - const newEntries = [...prevEntries, ...action.payload.entries]; - const { hasMoreAfterEnd, entriesEnd, lastLoadedTime } = action.payload; + const newEntries = action.payload.entries; + const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); + const entries = [...prevEntries, ...newEntries]; + const update = { - entries: newEntries, + entries, isLoadingMore: false, - hasMoreAfterEnd, - entriesEnd, - lastLoadedTime, + hasMoreAfterEnd: newEntries.length > 0, + // Keep the previous cursor if request comes empty, to easily extend the range. + bottomCursor: newEntries.length > 0 ? action.payload.bottomCursor : prevState.bottomCursor, + centerCursor: getCenterCursor(entries), + lastLoadedTime: new Date(), }; + return { ...prevState, ...update }; } case Action.FetchingNewEntries: - return { ...prevState, isReloading: true }; + return { + ...prevState, + isReloading: true, + entries: [], + topCursor: null, + bottomCursor: null, + centerCursor: null, + hasMoreBeforeStart: true, + hasMoreAfterEnd: true, + }; case Action.FetchingMoreEntries: return { ...prevState, isLoadingMore: true }; case Action.ErrorOnNewEntries: return { ...prevState, isReloading: false }; case Action.ErrorOnMoreEntries: return { ...prevState, isLoadingMore: false }; + + case Action.ExpandRange: { + const hasMoreBeforeStart = action.payload.before ? true : prevState.hasMoreBeforeStart; + const hasMoreAfterEnd = action.payload.after ? true : prevState.hasMoreAfterEnd; + + return { + ...prevState, + hasMoreBeforeStart, + hasMoreAfterEnd, + }; + } default: throw new Error(); } }; +function getCenterCursor(entries: LogEntry[]): TimeKey | null { + return entries.length > 0 ? entries[Math.floor(entries.length / 2)].cursor : null; +} + export const LogEntriesState = createContainer(useLogEntriesState); diff --git a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx index 5c1667a4b7680..267abe631c142 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx @@ -19,7 +19,7 @@ export enum FlyoutVisibility { visible = 'visible', } -interface FlyoutOptionsUrlState { +export interface FlyoutOptionsUrlState { flyoutId?: string | null; flyoutVisibility?: string | null; surroundingLogsId?: string | null; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts new file mode 100644 index 0000000000000..030a9d180c7b5 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; + +import { + LOG_ENTRIES_HIGHLIGHTS_PATH, + LogEntriesHighlightsRequest, + logEntriesHighlightsRequestRT, + logEntriesHighlightsResponseRT, +} from '../../../../../common/http_api'; + +export const fetchLogEntriesHighlights = async (requestArgs: LogEntriesHighlightsRequest) => { + const response = await npStart.http.fetch(LOG_ENTRIES_HIGHLIGHTS_PATH, { + method: 'POST', + body: JSON.stringify(logEntriesHighlightsRequestRT.encode(requestArgs)), + }); + + return pipe( + logEntriesHighlightsResponseRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx index 2b19958a9b1a1..7701850443768 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx @@ -6,62 +6,47 @@ import { useEffect, useMemo, useState } from 'react'; -import { getNextTimeKey, getPreviousTimeKey, TimeKey } from '../../../../common/time'; -import { LogEntryHighlightsQuery } from '../../../graphql/types'; -import { DependencyError, useApolloClient } from '../../../utils/apollo_context'; -import { LogEntryHighlightsMap } from '../../../utils/log_entry'; +import { TimeKey } from '../../../../common/time'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { logEntryHighlightsQuery } from './log_entry_highlights.gql_query'; - -export type LogEntryHighlights = LogEntryHighlightsQuery.Query['source']['logEntryHighlights']; +import { fetchLogEntriesHighlights } from './api/fetch_log_entries_highlights'; +import { LogEntry, LogEntriesHighlightsResponse } from '../../../../common/http_api'; export const useLogEntryHighlights = ( sourceId: string, sourceVersion: string | undefined, - startKey: TimeKey | null, - endKey: TimeKey | null, + startTimestamp: number | null, + endTimestamp: number | null, + centerPoint: TimeKey | null, + size: number, filterQuery: string | null, highlightTerms: string[] ) => { - const apolloClient = useApolloClient(); - const [logEntryHighlights, setLogEntryHighlights] = useState([]); + const [logEntryHighlights, setLogEntryHighlights] = useState< + LogEntriesHighlightsResponse['data'] + >([]); const [loadLogEntryHighlightsRequest, loadLogEntryHighlights] = useTrackedPromise( { cancelPreviousOn: 'resolution', createPromise: async () => { - if (!apolloClient) { - throw new DependencyError('Failed to load source: No apollo client available.'); - } - if (!startKey || !endKey || !highlightTerms.length) { + if (!startTimestamp || !endTimestamp || !centerPoint || !highlightTerms.length) { throw new Error('Skipping request: Insufficient parameters'); } - return await apolloClient.query< - LogEntryHighlightsQuery.Query, - LogEntryHighlightsQuery.Variables - >({ - fetchPolicy: 'no-cache', - query: logEntryHighlightsQuery, - variables: { - sourceId, - startKey: getPreviousTimeKey(startKey), // interval boundaries are exclusive - endKey: getNextTimeKey(endKey), // interval boundaries are exclusive - filterQuery, - highlights: [ - { - query: highlightTerms[0], - countBefore: 1, - countAfter: 1, - }, - ], - }, + return await fetchLogEntriesHighlights({ + sourceId, + startTimestamp, + endTimestamp, + center: centerPoint, + size, + query: filterQuery || undefined, + highlightTerms, }); }, onResolve: response => { - setLogEntryHighlights(response.data.source.logEntryHighlights); + setLogEntryHighlights(response.data); }, }, - [apolloClient, sourceId, startKey, endKey, filterQuery, highlightTerms] + [sourceId, startTimestamp, endTimestamp, centerPoint, size, filterQuery, highlightTerms] ); useEffect(() => { @@ -71,24 +56,31 @@ export const useLogEntryHighlights = ( useEffect(() => { if ( highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && - startKey && - endKey + startTimestamp && + endTimestamp ) { loadLogEntryHighlights(); } else { setLogEntryHighlights([]); } - }, [endKey, filterQuery, highlightTerms, loadLogEntryHighlights, sourceVersion, startKey]); + }, [ + endTimestamp, + filterQuery, + highlightTerms, + loadLogEntryHighlights, + sourceVersion, + startTimestamp, + ]); const logEntryHighlightsById = useMemo( () => - logEntryHighlights.reduce( - (accumulatedLogEntryHighlightsById, { entries }) => { - return entries.reduce((singleHighlightLogEntriesById, entry) => { - const highlightsForId = singleHighlightLogEntriesById[entry.gid] || []; + logEntryHighlights.reduce>( + (accumulatedLogEntryHighlightsById, highlightData) => { + return highlightData.entries.reduce((singleHighlightLogEntriesById, entry) => { + const highlightsForId = singleHighlightLogEntriesById[entry.id] || []; return { ...singleHighlightLogEntriesById, - [entry.gid]: [...highlightsForId, entry], + [entry.id]: [...highlightsForId, entry], }; }, accumulatedLogEntryHighlightsById); }, diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx index a4a94851ad383..941e89848131b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx @@ -6,39 +6,38 @@ import createContainer from 'constate'; import { useState, useContext } from 'react'; +import { useThrottle } from 'react-use'; import { useLogEntryHighlights } from './log_entry_highlights'; import { useLogSummaryHighlights } from './log_summary_highlights'; import { useNextAndPrevious } from './next_and_previous'; -import { useLogSummaryBufferInterval } from '../log_summary'; -import { LogViewConfiguration } from '../log_view_configuration'; import { LogPositionState } from '../log_position'; import { TimeKey } from '../../../../common/time'; +const FETCH_THROTTLE_INTERVAL = 3000; + +interface UseLogHighlightsStateProps { + sourceId: string; + sourceVersion: string | undefined; + centerCursor: TimeKey | null; + size: number; + filterQuery: string | null; +} + export const useLogHighlightsState = ({ sourceId, sourceVersion, - entriesStart, - entriesEnd, + centerCursor, + size, filterQuery, -}: { - sourceId: string; - sourceVersion: string | undefined; - entriesStart: TimeKey | null; - entriesEnd: TimeKey | null; - filterQuery: string | null; -}) => { +}: UseLogHighlightsStateProps) => { const [highlightTerms, setHighlightTerms] = useState([]); - const { visibleMidpoint, jumpToTargetPosition } = useContext(LogPositionState.Context); - const { intervalSize: summaryIntervalSize } = useContext(LogViewConfiguration.Context); - const { - start: summaryStart, - end: summaryEnd, - bucketSize: summaryBucketSize, - } = useLogSummaryBufferInterval( - visibleMidpoint ? visibleMidpoint.time : null, - summaryIntervalSize + const { visibleMidpoint, jumpToTargetPosition, startTimestamp, endTimestamp } = useContext( + LogPositionState.Context ); + const throttledStartTimestamp = useThrottle(startTimestamp, FETCH_THROTTLE_INTERVAL); + const throttledEndTimestamp = useThrottle(endTimestamp, FETCH_THROTTLE_INTERVAL); + const { logEntryHighlights, logEntryHighlightsById, @@ -46,8 +45,10 @@ export const useLogHighlightsState = ({ } = useLogEntryHighlights( sourceId, sourceVersion, - entriesStart, - entriesEnd, + throttledStartTimestamp, + throttledEndTimestamp, + centerCursor, + size, filterQuery, highlightTerms ); @@ -55,9 +56,8 @@ export const useLogHighlightsState = ({ const { logSummaryHighlights, loadLogSummaryHighlightsRequest } = useLogSummaryHighlights( sourceId, sourceVersion, - summaryStart, - summaryEnd, - summaryBucketSize, + throttledStartTimestamp, + throttledEndTimestamp, filterQuery, highlightTerms ); diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts index 81639aba411ef..41ee63bf0e23d 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts @@ -10,13 +10,13 @@ import { debounce } from 'lodash'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { fetchLogSummaryHighlights } from './api/fetch_log_summary_highlights'; import { LogEntriesSummaryHighlightsResponse } from '../../../../common/http_api'; +import { useBucketSize } from '../log_summary/bucket_size'; export const useLogSummaryHighlights = ( sourceId: string, sourceVersion: string | undefined, - start: number | null, - end: number | null, - bucketSize: number, + startTimestamp: number | null, + endTimestamp: number | null, filterQuery: string | null, highlightTerms: string[] ) => { @@ -24,18 +24,20 @@ export const useLogSummaryHighlights = ( LogEntriesSummaryHighlightsResponse['data'] >([]); + const bucketSize = useBucketSize(startTimestamp, endTimestamp); + const [loadLogSummaryHighlightsRequest, loadLogSummaryHighlights] = useTrackedPromise( { cancelPreviousOn: 'resolution', createPromise: async () => { - if (!start || !end || !highlightTerms.length) { + if (!startTimestamp || !endTimestamp || !bucketSize || !highlightTerms.length) { throw new Error('Skipping request: Insufficient parameters'); } return await fetchLogSummaryHighlights({ sourceId, - startDate: start, - endDate: end, + startTimestamp, + endTimestamp, bucketSize, query: filterQuery, highlightTerms, @@ -45,7 +47,7 @@ export const useLogSummaryHighlights = ( setLogSummaryHighlights(response.data); }, }, - [sourceId, start, end, bucketSize, filterQuery, highlightTerms] + [sourceId, startTimestamp, endTimestamp, bucketSize, filterQuery, highlightTerms] ); const debouncedLoadSummaryHighlights = useMemo(() => debounce(loadLogSummaryHighlights, 275), [ @@ -57,7 +59,11 @@ export const useLogSummaryHighlights = ( }, [highlightTerms]); useEffect(() => { - if (highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && start && end) { + if ( + highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && + startTimestamp && + endTimestamp + ) { debouncedLoadSummaryHighlights(); } else { setLogSummaryHighlights([]); @@ -65,11 +71,11 @@ export const useLogSummaryHighlights = ( }, [ bucketSize, debouncedLoadSummaryHighlights, - end, filterQuery, highlightTerms, sourceVersion, - start, + startTimestamp, + endTimestamp, ]); return { diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx index 7557550883f11..689c30a52b597 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx @@ -13,7 +13,7 @@ import { getLogEntryIndexBeforeTime, getUniqueLogEntryKey, } from '../../../utils/log_entry'; -import { LogEntryHighlights } from './log_entry_highlights'; +import { LogEntriesHighlightsResponse } from '../../../../common/http_api'; export const useNextAndPrevious = ({ highlightTerms, @@ -23,7 +23,7 @@ export const useNextAndPrevious = ({ }: { highlightTerms: string[]; jumpToTargetPosition: (target: TimeKey) => void; - logEntryHighlights: LogEntryHighlights | undefined; + logEntryHighlights: LogEntriesHighlightsResponse['data'] | undefined; visibleMidpoint: TimeKey | null; }) => { const [currentTimeKey, setCurrentTimeKey] = useState(null); diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts index 1a8274024bd26..5ac34e5df70ec 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts @@ -6,10 +6,20 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import createContainer from 'constate'; +import { useSetState } from 'react-use'; import { TimeKey } from '../../../../common/time'; +import { datemathToEpochMillis, isValidDatemath } from '../../../utils/datemath'; type TimeKeyOrNull = TimeKey | null; +interface DateRange { + startDateExpression: string; + endDateExpression: string; + startTimestamp: number; + endTimestamp: number; + timestampsLastUpdate: number; +} + interface VisiblePositions { startKey: TimeKeyOrNull; middleKey: TimeKeyOrNull; @@ -19,24 +29,35 @@ interface VisiblePositions { } export interface LogPositionStateParams { + isInitialized: boolean; targetPosition: TimeKeyOrNull; - isAutoReloading: boolean; + isStreaming: boolean; firstVisiblePosition: TimeKeyOrNull; pagesBeforeStart: number; pagesAfterEnd: number; visibleMidpoint: TimeKeyOrNull; visibleMidpointTime: number | null; visibleTimeInterval: { start: number; end: number } | null; + startDateExpression: string; + endDateExpression: string; + startTimestamp: number | null; + endTimestamp: number | null; + timestampsLastUpdate: number; } export interface LogPositionCallbacks { + initialize: () => void; jumpToTargetPosition: (pos: TimeKeyOrNull) => void; jumpToTargetPositionTime: (time: number) => void; reportVisiblePositions: (visPos: VisiblePositions) => void; startLiveStreaming: () => void; stopLiveStreaming: () => void; + updateDateRange: (newDateRage: Partial) => void; } +const DEFAULT_DATE_RANGE = { startDateExpression: 'now-1d', endDateExpression: 'now' }; +const DESIRED_BUFFER_PAGES = 2; + const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrNull) => { // Of the two dependencies `middleKey` and `targetPosition`, return // whichever one was the most recently updated. This allows the UI controls @@ -60,8 +81,18 @@ const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrN }; export const useLogPositionState: () => LogPositionStateParams & LogPositionCallbacks = () => { + // Flag to determine if `LogPositionState` has been fully initialized. + // + // When the page loads, there might be initial state in the URL. We want to + // prevent the entries from showing until we have processed that initial + // state. That prevents double fetching. + const [isInitialized, setInitialized] = useState(false); + const initialize = useCallback(() => { + setInitialized(true); + }, [setInitialized]); + const [targetPosition, jumpToTargetPosition] = useState(null); - const [isAutoReloading, setIsAutoReloading] = useState(false); + const [isStreaming, setIsStreaming] = useState(false); const [visiblePositions, reportVisiblePositions] = useState({ endKey: null, middleKey: null, @@ -70,6 +101,15 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall pagesAfterEnd: Infinity, }); + // We group the `startDate` and `endDate` values in the same object to be able + // to set both at the same time, saving a re-render + const [dateRange, setDateRange] = useSetState({ + ...DEFAULT_DATE_RANGE, + startTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.startDateExpression)!, + endTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.endDateExpression, 'up')!, + timestampsLastUpdate: Date.now(), + }); + const { startKey, middleKey, endKey, pagesBeforeStart, pagesAfterEnd } = visiblePositions; const visibleMidpoint = useVisibleMidpoint(middleKey, targetPosition); @@ -79,26 +119,87 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall [startKey, endKey] ); + // Allow setting `startDate` and `endDate` separately, or together + const updateDateRange = useCallback( + (newDateRange: Partial) => { + // Prevent unnecessary re-renders + if (!('startDateExpression' in newDateRange) && !('endDateExpression' in newDateRange)) { + return; + } + + const nextStartDateExpression = + newDateRange.startDateExpression || dateRange.startDateExpression; + const nextEndDateExpression = newDateRange.endDateExpression || dateRange.endDateExpression; + + if (!isValidDatemath(nextStartDateExpression) || !isValidDatemath(nextEndDateExpression)) { + return; + } + + // Dates are valid, so the function cannot return `null` + const nextStartTimestamp = datemathToEpochMillis(nextStartDateExpression)!; + const nextEndTimestamp = datemathToEpochMillis(nextEndDateExpression, 'up')!; + + // Reset the target position if it doesn't fall within the new range. + if ( + targetPosition && + (nextStartTimestamp > targetPosition.time || nextEndTimestamp < targetPosition.time) + ) { + jumpToTargetPosition(null); + } + + setDateRange({ + ...newDateRange, + startTimestamp: nextStartTimestamp, + endTimestamp: nextEndTimestamp, + timestampsLastUpdate: Date.now(), + }); + }, + [setDateRange, dateRange, targetPosition] + ); + + // `endTimestamp` update conditions + useEffect(() => { + if (dateRange.endDateExpression !== 'now') { + return; + } + + // User is close to the bottom edge of the scroll. + if (visiblePositions.pagesAfterEnd <= DESIRED_BUFFER_PAGES) { + setDateRange({ + endTimestamp: datemathToEpochMillis(dateRange.endDateExpression, 'up')!, + timestampsLastUpdate: Date.now(), + }); + } + }, [dateRange.endDateExpression, visiblePositions, setDateRange]); + const state = { + isInitialized, targetPosition, - isAutoReloading, + isStreaming, firstVisiblePosition: startKey, pagesBeforeStart, pagesAfterEnd, visibleMidpoint, visibleMidpointTime: visibleMidpoint ? visibleMidpoint.time : null, visibleTimeInterval, + ...dateRange, }; const callbacks = { + initialize, jumpToTargetPosition, jumpToTargetPositionTime: useCallback( (time: number) => jumpToTargetPosition({ tiebreaker: 0, time }), [jumpToTargetPosition] ), reportVisiblePositions, - startLiveStreaming: useCallback(() => setIsAutoReloading(true), [setIsAutoReloading]), - stopLiveStreaming: useCallback(() => setIsAutoReloading(false), [setIsAutoReloading]), + startLiveStreaming: useCallback(() => { + setIsStreaming(true); + jumpToTargetPosition(null); + updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }); + }, [setIsStreaming, updateDateRange]), + stopLiveStreaming: useCallback(() => setIsStreaming(false), [setIsStreaming]), + updateDateRange, }; return { ...state, ...callbacks }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx b/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx index 221dac95ef5f0..0d3586f9376f3 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx @@ -9,31 +9,40 @@ import React, { useContext, useMemo } from 'react'; import { pickTimeKey } from '../../../../common/time'; import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state'; import { LogPositionState, LogPositionStateParams } from './log_position_state'; +import { isValidDatemath, datemathToEpochMillis } from '../../../utils/datemath'; /** * Url State */ - -interface LogPositionUrlState { - position: LogPositionStateParams['visibleMidpoint'] | undefined; +export interface LogPositionUrlState { + position?: LogPositionStateParams['visibleMidpoint']; streamLive: boolean; + start?: string; + end?: string; } +const ONE_HOUR = 3600000; + export const WithLogPositionUrlState = () => { const { visibleMidpoint, - isAutoReloading, + isStreaming, jumpToTargetPosition, - jumpToTargetPositionTime, startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, + initialize, } = useContext(LogPositionState.Context); const urlState = useMemo( () => ({ position: visibleMidpoint ? pickTimeKey(visibleMidpoint) : null, - streamLive: isAutoReloading, + streamLive: isStreaming, + start: startDateExpression, + end: endDateExpression, }), - [visibleMidpoint, isAutoReloading] + [visibleMidpoint, isStreaming, startDateExpression, endDateExpression] ); return ( { urlStateKey="logPosition" mapToUrlState={mapToUrlState} onChange={(newUrlState: LogPositionUrlState | undefined) => { - if (newUrlState && newUrlState.position) { + if (!newUrlState) { + return; + } + + if (newUrlState.start || newUrlState.end) { + updateDateRange({ + startDateExpression: newUrlState.start, + endDateExpression: newUrlState.end, + }); + } + + if (newUrlState.position) { jumpToTargetPosition(newUrlState.position); } - if (newUrlState && newUrlState.streamLive) { + + if (newUrlState.streamLive) { startLiveStreaming(); - } else if ( - newUrlState && - typeof newUrlState.streamLive !== 'undefined' && - !newUrlState.streamLive - ) { + } else if (typeof newUrlState.streamLive !== 'undefined' && !newUrlState.streamLive) { stopLiveStreaming(); } }} onInitialize={(initialUrlState: LogPositionUrlState | undefined) => { - if (initialUrlState && initialUrlState.position) { - jumpToTargetPosition(initialUrlState.position); - } else { - jumpToTargetPositionTime(Date.now()); - } - if (initialUrlState && initialUrlState.streamLive) { - startLiveStreaming(); + if (initialUrlState) { + const initialPosition = initialUrlState.position; + let initialStartDateExpression = initialUrlState.start; + let initialEndDateExpression = initialUrlState.end; + + if (!initialPosition) { + initialStartDateExpression = initialStartDateExpression || 'now-1d'; + initialEndDateExpression = initialEndDateExpression || 'now'; + } else { + const initialStartTimestamp = initialStartDateExpression + ? datemathToEpochMillis(initialStartDateExpression) + : undefined; + const initialEndTimestamp = initialEndDateExpression + ? datemathToEpochMillis(initialEndDateExpression, 'up') + : undefined; + + // Adjust the start-end range if the target position falls outside or if it's not set. + if (!initialStartTimestamp || initialStartTimestamp > initialPosition.time) { + initialStartDateExpression = new Date(initialPosition.time - ONE_HOUR).toISOString(); + } + + if (!initialEndTimestamp || initialEndTimestamp < initialPosition.time) { + initialEndDateExpression = new Date(initialPosition.time + ONE_HOUR).toISOString(); + } + + jumpToTargetPosition(initialPosition); + } + + if (initialStartDateExpression || initialEndDateExpression) { + updateDateRange({ + startDateExpression: initialStartDateExpression, + endDateExpression: initialEndDateExpression, + }); + } + + if (initialUrlState.streamLive) { + startLiveStreaming(); + } } + + initialize(); }} /> ); @@ -73,6 +123,8 @@ const mapToUrlState = (value: any): LogPositionUrlState | undefined => ? { position: mapToPositionUrlState(value.position), streamLive: mapToStreamLiveUrlState(value.streamLive), + start: mapToDate(value.start), + end: mapToDate(value.end), } : undefined; @@ -83,6 +135,7 @@ const mapToPositionUrlState = (value: any) => const mapToStreamLiveUrlState = (value: any) => (typeof value === 'boolean' ? value : false); +const mapToDate = (value: any) => (isValidDatemath(value) ? value : undefined); export const replaceLogPositionInQueryString = (time: number) => Number.isNaN(time) ? (value: string) => value diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts new file mode 100644 index 0000000000000..e46b304156f83 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo } from 'react'; + +const SUMMARY_BUCKET_COUNT = 100; + +export function useBucketSize( + startTimestamp: number | null, + endTimestamp: number | null +): number | null { + const bucketSize = useMemo(() => { + if (!startTimestamp || !endTimestamp) { + return null; + } + return (endTimestamp - startTimestamp) / SUMMARY_BUCKET_COUNT; + }, [startTimestamp, endTimestamp]); + + return bucketSize; +} diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts index 20c4267000a25..dc0437fa75a31 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts @@ -5,5 +5,4 @@ */ export * from './log_summary'; -export * from './use_log_summary_buffer_interval'; export * from './with_summary'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx index 2bbcc22b150e4..73d0e5efdf06b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx @@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { useLogSummary } from './log_summary'; import { fetchLogSummary } from './api/fetch_log_summary'; +import { datemathToEpochMillis } from '../../../utils/datemath'; // Typescript doesn't know that `fetchLogSummary` is a jest mock. // We use a second variable with a type cast to help the compiler further down the line. @@ -21,20 +22,26 @@ describe('useLogSummary hook', () => { }); it('provides an empty list of buckets by default', () => { - const { result } = renderHook(() => useLogSummary('SOURCE_ID', null, 1000, null)); + const { result } = renderHook(() => useLogSummary('SOURCE_ID', null, null, null)); expect(result.current.buckets).toEqual([]); }); it('queries for new summary buckets when the source id changes', async () => { - const firstMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 1 }]); - const secondMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 2 }]); + const { startTimestamp, endTimestamp } = createMockDateRange(); + + const firstMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 1 }, + ]); + const secondMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 2 }, + ]); fetchLogSummaryMock .mockResolvedValueOnce(firstMockResponse) .mockResolvedValueOnce(secondMockResponse); const { result, waitForNextUpdate, rerender } = renderHook( - ({ sourceId }) => useLogSummary(sourceId, 100000, 1000, null), + ({ sourceId }) => useLogSummary(sourceId, startTimestamp, endTimestamp, null), { initialProps: { sourceId: 'INITIAL_SOURCE_ID' }, } @@ -63,15 +70,21 @@ describe('useLogSummary hook', () => { }); it('queries for new summary buckets when the filter query changes', async () => { - const firstMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 1 }]); - const secondMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 2 }]); + const { startTimestamp, endTimestamp } = createMockDateRange(); + + const firstMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 1 }, + ]); + const secondMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 2 }, + ]); fetchLogSummaryMock .mockResolvedValueOnce(firstMockResponse) .mockResolvedValueOnce(secondMockResponse); const { result, waitForNextUpdate, rerender } = renderHook( - ({ filterQuery }) => useLogSummary('SOURCE_ID', 100000, 1000, filterQuery), + ({ filterQuery }) => useLogSummary('SOURCE_ID', startTimestamp, endTimestamp, filterQuery), { initialProps: { filterQuery: 'INITIAL_FILTER_QUERY' }, } @@ -99,15 +112,17 @@ describe('useLogSummary hook', () => { expect(result.current.buckets).toEqual(secondMockResponse.data.buckets); }); - it('queries for new summary buckets when the midpoint time changes', async () => { + it('queries for new summary buckets when the start and end date changes', async () => { fetchLogSummaryMock .mockResolvedValueOnce(createMockResponse([])) .mockResolvedValueOnce(createMockResponse([])); + const firstRange = createMockDateRange(); const { waitForNextUpdate, rerender } = renderHook( - ({ midpointTime }) => useLogSummary('SOURCE_ID', midpointTime, 1000, null), + ({ startTimestamp, endTimestamp }) => + useLogSummary('SOURCE_ID', startTimestamp, endTimestamp, null), { - initialProps: { midpointTime: 100000 }, + initialProps: firstRange, } ); @@ -115,54 +130,21 @@ describe('useLogSummary hook', () => { expect(fetchLogSummaryMock).toHaveBeenCalledTimes(1); expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( expect.objectContaining({ - startDate: 98500, - endDate: 101500, - }) - ); - - rerender({ midpointTime: 200000 }); - await waitForNextUpdate(); - - expect(fetchLogSummaryMock).toHaveBeenCalledTimes(2); - expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - startDate: 198500, - endDate: 201500, + startTimestamp: firstRange.startTimestamp, + endTimestamp: firstRange.endTimestamp, }) ); - }); - it('queries for new summary buckets when the interval size changes', async () => { - fetchLogSummaryMock - .mockResolvedValueOnce(createMockResponse([])) - .mockResolvedValueOnce(createMockResponse([])); - - const { waitForNextUpdate, rerender } = renderHook( - ({ intervalSize }) => useLogSummary('SOURCE_ID', 100000, intervalSize, null), - { - initialProps: { intervalSize: 1000 }, - } - ); + const secondRange = createMockDateRange('now-20s', 'now'); - await waitForNextUpdate(); - expect(fetchLogSummaryMock).toHaveBeenCalledTimes(1); - expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - bucketSize: 10, - startDate: 98500, - endDate: 101500, - }) - ); - - rerender({ intervalSize: 2000 }); + rerender(secondRange); await waitForNextUpdate(); expect(fetchLogSummaryMock).toHaveBeenCalledTimes(2); expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( expect.objectContaining({ - bucketSize: 20, - startDate: 97000, - endDate: 103000, + startTimestamp: secondRange.startTimestamp, + endTimestamp: secondRange.endTimestamp, }) ); }); @@ -171,3 +153,12 @@ describe('useLogSummary hook', () => { const createMockResponse = ( buckets: Array<{ start: number; end: number; entriesCount: number }> ) => ({ data: { buckets, start: Number.NEGATIVE_INFINITY, end: Number.POSITIVE_INFINITY } }); + +const createMockDateRange = (startDate = 'now-10s', endDate = 'now') => { + return { + startDate, + endDate, + startTimestamp: datemathToEpochMillis(startDate)!, + endTimestamp: datemathToEpochMillis(endDate, 'up')!, + }; +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx index c39b7075af325..94723125cc0ec 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx @@ -7,34 +7,31 @@ import { useState } from 'react'; import { useCancellableEffect } from '../../../utils/cancellable_effect'; -import { useLogSummaryBufferInterval } from './use_log_summary_buffer_interval'; import { fetchLogSummary } from './api/fetch_log_summary'; import { LogEntriesSummaryResponse } from '../../../../common/http_api'; +import { useBucketSize } from './bucket_size'; export type LogSummaryBuckets = LogEntriesSummaryResponse['data']['buckets']; export const useLogSummary = ( sourceId: string, - midpointTime: number | null, - intervalSize: number, + startTimestamp: number | null, + endTimestamp: number | null, filterQuery: string | null ) => { const [logSummaryBuckets, setLogSummaryBuckets] = useState([]); - const { start: bufferStart, end: bufferEnd, bucketSize } = useLogSummaryBufferInterval( - midpointTime, - intervalSize - ); + const bucketSize = useBucketSize(startTimestamp, endTimestamp); useCancellableEffect( getIsCancelled => { - if (bufferStart === null || bufferEnd === null) { + if (startTimestamp === null || endTimestamp === null || bucketSize === null) { return; } fetchLogSummary({ sourceId, - startDate: bufferStart, - endDate: bufferEnd, + startTimestamp, + endTimestamp, bucketSize, query: filterQuery, }).then(response => { @@ -43,12 +40,12 @@ export const useLogSummary = ( } }); }, - [sourceId, filterQuery, bufferStart, bufferEnd, bucketSize] + [sourceId, filterQuery, startTimestamp, endTimestamp, bucketSize] ); return { buckets: logSummaryBuckets, - start: bufferStart, - end: bufferEnd, + start: startTimestamp, + end: endTimestamp, }; }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts deleted file mode 100644 index 27af76b70f47a..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useMemo } from 'react'; - -const LOAD_BUCKETS_PER_PAGE = 100; -const UNKNOWN_BUFFER_INTERVAL = { - start: null, - end: null, - bucketSize: 0, -}; - -export const useLogSummaryBufferInterval = (midpointTime: number | null, intervalSize: number) => { - return useMemo(() => { - if (midpointTime === null || intervalSize <= 0) { - return UNKNOWN_BUFFER_INTERVAL; - } - - const halfIntervalSize = intervalSize / 2; - - return { - start: (Math.floor((midpointTime - halfIntervalSize) / intervalSize) - 0.5) * intervalSize, - end: (Math.ceil((midpointTime + halfIntervalSize) / intervalSize) + 0.5) * intervalSize, - bucketSize: intervalSize / LOAD_BUCKETS_PER_PAGE, - }; - }, [midpointTime, intervalSize]); -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts index 4db0d2e645448..14da2b47bcfa2 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts @@ -5,14 +5,16 @@ */ import { useContext } from 'react'; +import { useThrottle } from 'react-use'; import { RendererFunction } from '../../../utils/typed_react'; import { Source } from '../../source'; -import { LogViewConfiguration } from '../log_view_configuration'; import { LogSummaryBuckets, useLogSummary } from './log_summary'; import { LogFilterState } from '../log_filter'; import { LogPositionState } from '../log_position'; +const FETCH_THROTTLE_INTERVAL = 3000; + export const WithSummary = ({ children, }: { @@ -22,15 +24,18 @@ export const WithSummary = ({ end: number | null; }>; }) => { - const { intervalSize } = useContext(LogViewConfiguration.Context); const { sourceId } = useContext(Source.Context); const { filterQuery } = useContext(LogFilterState.Context); - const { visibleMidpointTime } = useContext(LogPositionState.Context); + const { startTimestamp, endTimestamp } = useContext(LogPositionState.Context); + + // Keep it reasonably updated for the `now` case, but don't reload all the time when the user scrolls + const throttledStartTimestamp = useThrottle(startTimestamp, FETCH_THROTTLE_INTERVAL); + const throttledEndTimestamp = useThrottle(endTimestamp, FETCH_THROTTLE_INTERVAL); const { buckets, start, end } = useLogSummary( sourceId, - visibleMidpointTime, - intervalSize, + throttledStartTimestamp, + throttledEndTimestamp, filterQuery ); diff --git a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx index b6de1230d9a59..5954cb834a11d 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx @@ -45,35 +45,10 @@ describe('useLogViewConfiguration hook', () => { }); }); - describe('intervalSize state', () => { - it('has a default value', () => { - const { getLastHookValue } = mountHook(() => useLogViewConfiguration().intervalSize); - - expect(getLastHookValue()).toEqual(86400000); - }); - - it('can be updated', () => { - const { act, getLastHookValue } = mountHook(() => useLogViewConfiguration()); - - act(({ setIntervalSize }) => { - setIntervalSize(90000000); - }); - - expect(getLastHookValue().intervalSize).toEqual(90000000); - }); - }); - it('provides the available text scales', () => { const { getLastHookValue } = mountHook(() => useLogViewConfiguration().availableTextScales); expect(getLastHookValue()).toEqual(expect.any(Array)); expect(getLastHookValue().length).toBeGreaterThan(0); }); - - it('provides the available interval sizes', () => { - const { getLastHookValue } = mountHook(() => useLogViewConfiguration().availableIntervalSizes); - - expect(getLastHookValue()).toEqual(expect.any(Array)); - expect(getLastHookValue().length).toBeGreaterThan(0); - }); }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx index 8837078aa4a0d..e1351ad0b17ad 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import createContainer from 'constate'; import { useState } from 'react'; @@ -17,18 +16,12 @@ export const useLogViewConfiguration = () => { // text wrap const [textWrap, setTextWrap] = useState(true); - // minimap interval - const [intervalSize, setIntervalSize] = useState(1000 * 60 * 60 * 24); - return { - availableIntervalSizes, availableTextScales, setTextScale, setTextWrap, textScale, textWrap, - intervalSize, - setIntervalSize, }; }; @@ -39,42 +32,3 @@ export const LogViewConfiguration = createContainer(useLogViewConfiguration); */ export const availableTextScales: TextScale[] = ['large', 'medium', 'small']; - -export const availableIntervalSizes = [ - { - label: i18n.translate('xpack.infra.mapLogs.oneYearLabel', { - defaultMessage: '1 Year', - }), - intervalSize: 1000 * 60 * 60 * 24 * 365, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneMonthLabel', { - defaultMessage: '1 Month', - }), - intervalSize: 1000 * 60 * 60 * 24 * 30, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneWeekLabel', { - defaultMessage: '1 Week', - }), - intervalSize: 1000 * 60 * 60 * 24 * 7, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneDayLabel', { - defaultMessage: '1 Day', - }), - intervalSize: 1000 * 60 * 60 * 24, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneHourLabel', { - defaultMessage: '1 Hour', - }), - intervalSize: 1000 * 60 * 60, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneMinuteLabel', { - defaultMessage: '1 Minute', - }), - intervalSize: 1000 * 60, - }, -]; diff --git a/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx b/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx deleted file mode 100644 index 3f2b4d7cc16f9..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx +++ /dev/null @@ -1,52 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useContext, useMemo } from 'react'; - -import { UrlStateContainer } from '../../utils/url_state'; -import { LogViewConfiguration } from './log_view_configuration'; - -/** - * Url State - */ - -interface LogMinimapUrlState { - intervalSize?: number; -} - -export const WithLogMinimapUrlState = () => { - const { intervalSize, setIntervalSize } = useContext(LogViewConfiguration.Context); - - const urlState = useMemo(() => ({ intervalSize }), [intervalSize]); - - return ( - { - if (newUrlState && newUrlState.intervalSize) { - setIntervalSize(newUrlState.intervalSize); - } - }} - onInitialize={newUrlState => { - if (newUrlState && newUrlState.intervalSize) { - setIntervalSize(newUrlState.intervalSize); - } - }} - /> - ); -}; - -const mapToUrlState = (value: any): LogMinimapUrlState | undefined => - value - ? { - intervalSize: mapToIntervalSizeUrlState(value.intervalSize), - } - : undefined; - -const mapToIntervalSizeUrlState = (value: any) => - value && typeof value === 'number' ? value : undefined; diff --git a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts index 6da9cd7513cba..5c0e245448ce5 100644 --- a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts +++ b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts @@ -6,12 +6,12 @@ import { useContext, useMemo } from 'react'; import { StreamItem, LogEntryStreamItem } from '../../components/logging/log_text_stream/item'; -import { LogEntry, LogEntryHighlight } from '../../utils/log_entry'; import { RendererFunction } from '../../utils/typed_react'; // deep inporting to avoid a circular import problem import { LogHighlightsState } from './log_highlights/log_highlights'; import { LogEntriesState, LogEntriesStateParams, LogEntriesCallbacks } from './log_entries'; import { UniqueTimeKey } from '../../../common/time'; +import { LogEntry } from '../../../common/http_api'; export const WithStreamItems: React.FunctionComponent<{ children: RendererFunction< @@ -30,7 +30,7 @@ export const WithStreamItems: React.FunctionComponent<{ logEntries.isReloading ? [] : logEntries.entries.map(logEntry => - createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.gid] || []) + createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.id] || []) ), [logEntries.entries, logEntries.isReloading, logEntryHighlightsById] @@ -46,7 +46,7 @@ export const WithStreamItems: React.FunctionComponent<{ const createLogEntryStreamItem = ( logEntry: LogEntry, - highlights: LogEntryHighlight[] + highlights: LogEntry[] ): LogEntryStreamItem => ({ kind: 'logEntry' as 'logEntry', logEntry, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx index 54609bcf8e2c2..023082154565c 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx @@ -44,11 +44,8 @@ export const CategoryExampleMessage: React.FunctionComponent<{ { const { source, sourceId, version } = useContext(Source.Context); - const { intervalSize, textScale, textWrap } = useContext(LogViewConfiguration.Context); + const { textScale, textWrap } = useContext(LogViewConfiguration.Context); const { setFlyoutVisibility, flyoutVisible, @@ -44,17 +43,20 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { const { logSummaryHighlights } = useContext(LogHighlightsState.Context); const { applyLogFilterQuery } = useContext(LogFilterState.Context); const { - isAutoReloading, + isStreaming, targetPosition, visibleMidpointTime, visibleTimeInterval, reportVisiblePositions, jumpToTargetPosition, + startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, } = useContext(LogPositionState.Context); return ( <> - @@ -90,7 +92,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { hasMoreBeforeStart={hasMoreBeforeStart} isLoadingMore={isLoadingMore} isReloading={isReloading} - isStreaming={isAutoReloading} + isStreaming={isStreaming} items={items} jumpToTarget={jumpToTargetPosition} lastLoadedTime={lastLoadedTime} @@ -104,6 +106,10 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { setFlyoutVisibility={setFlyoutVisibility} highlightedItem={surroundingLogsId ? surroundingLogsId : null} currentHighlightKey={currentHighlightKey} + startDateExpression={startDateExpression} + endDateExpression={endDateExpression} + updateDateRange={updateDateRange} + startLiveStreaming={startLiveStreaming} /> )} @@ -113,14 +119,15 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { return ( - {({ buckets }) => ( + {({ buckets, start, end }) => ( {({ isReloading }) => ( { const LogEntriesStateProvider: React.FC = ({ children }) => { const { sourceId } = useContext(Source.Context); const { + startTimestamp, + endTimestamp, + timestampsLastUpdate, targetPosition, pagesBeforeStart, pagesAfterEnd, - isAutoReloading, + isStreaming, jumpToTargetPosition, + isInitialized, } = useContext(LogPositionState.Context); const { filterQuery } = useContext(LogFilterState.Context); + // Don't render anything if the date range is incorrect. + if (!startTimestamp || !endTimestamp) { + return null; + } + const entriesProps = { + startTimestamp, + endTimestamp, + timestampsLastUpdate, timeKey: targetPosition, pagesBeforeStart, pagesAfterEnd, filterQuery, sourceId, - isAutoReloading, + isStreaming, jumpToTargetPosition, }; + + // Don't initialize the entries until the position has been fully intialized. + // See `` + if (!isInitialized) { + return null; + } + return {children}; }; const LogHighlightsStateProvider: React.FC = ({ children }) => { const { sourceId, version } = useContext(Source.Context); - const [{ entriesStart, entriesEnd }] = useContext(LogEntriesState.Context); + const [{ topCursor, bottomCursor, centerCursor, entries }] = useContext(LogEntriesState.Context); const { filterQuery } = useContext(LogFilterState.Context); + const highlightsProps = { sourceId, sourceVersion: version, - entriesStart, - entriesEnd, + entriesStart: topCursor, + entriesEnd: bottomCursor, + centerCursor, + size: entries.length, filterQuery, }; return {children}; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 000dfd1065f12..2f9a76fd47490 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -13,30 +13,22 @@ import { Toolbar } from '../../../components/eui'; import { LogCustomizationMenu } from '../../../components/logging/log_customization_menu'; import { LogHighlightsMenu } from '../../../components/logging/log_highlights_menu'; import { LogHighlightsState } from '../../../containers/logs/log_highlights/log_highlights'; -import { LogMinimapScaleControls } from '../../../components/logging/log_minimap_scale_controls'; import { LogTextScaleControls } from '../../../components/logging/log_text_scale_controls'; import { LogTextWrapControls } from '../../../components/logging/log_text_wrap_controls'; -import { LogTimeControls } from '../../../components/logging/log_time_controls'; import { LogFlyout } from '../../../containers/logs/log_flyout'; import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration'; import { LogFilterState } from '../../../containers/logs/log_filter'; import { LogPositionState } from '../../../containers/logs/log_position'; import { Source } from '../../../containers/source'; import { WithKueryAutocompletion } from '../../../containers/with_kuery_autocompletion'; +import { LogDatepicker } from '../../../components/logging/log_datepicker'; export const LogsToolbar = () => { const { createDerivedIndexPattern } = useContext(Source.Context); const derivedIndexPattern = createDerivedIndexPattern('logs'); - const { - availableIntervalSizes, - availableTextScales, - intervalSize, - setIntervalSize, - setTextScale, - setTextWrap, - textScale, - textWrap, - } = useContext(LogViewConfiguration.Context); + const { availableTextScales, setTextScale, setTextWrap, textScale, textWrap } = useContext( + LogViewConfiguration.Context + ); const { filterQueryDraft, isFilterQueryDraftValid, @@ -55,12 +47,14 @@ export const LogsToolbar = () => { goToNextHighlight, } = useContext(LogHighlightsState.Context); const { - visibleMidpointTime, - isAutoReloading, - jumpToTargetPositionTime, + isStreaming, startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, } = useContext(LogPositionState.Context); + return ( @@ -94,11 +88,6 @@ export const LogsToolbar = () => { - { /> - { - startLiveStreaming(); - setSurroundingLogsId(null); - }} - stopLiveStreaming={stopLiveStreaming} + diff --git a/x-pack/plugins/infra/public/utils/datemath.test.ts b/x-pack/plugins/infra/public/utils/datemath.test.ts new file mode 100644 index 0000000000000..0f272733c5f97 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/datemath.test.ts @@ -0,0 +1,401 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + isValidDatemath, + datemathToEpochMillis, + extendDatemath, + convertDate, + normalizeDate, +} from './datemath'; +import sinon from 'sinon'; + +describe('isValidDatemath()', () => { + it('Returns `false` for empty strings', () => { + expect(isValidDatemath('')).toBe(false); + }); + + it('Returns `false` for invalid strings', () => { + expect(isValidDatemath('wadus')).toBe(false); + expect(isValidDatemath('nowww-')).toBe(false); + expect(isValidDatemath('now-')).toBe(false); + expect(isValidDatemath('now-1')).toBe(false); + expect(isValidDatemath('now-1d/')).toBe(false); + }); + + it('Returns `true` for valid strings', () => { + expect(isValidDatemath('now')).toBe(true); + expect(isValidDatemath('now-1d')).toBe(true); + expect(isValidDatemath('now-1d/d')).toBe(true); + }); +}); + +describe('datemathToEpochMillis()', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(Date.now()); + }); + + afterEach(() => { + clock.restore(); + }); + + it('Returns `0` for the dawn of time', () => { + expect(datemathToEpochMillis('1970-01-01T00:00:00+00:00')).toEqual(0); + }); + + it('Returns the current timestamp when `now`', () => { + expect(datemathToEpochMillis('now')).toEqual(Date.now()); + }); +}); + +describe('extendDatemath()', () => { + it('Returns `undefined` for invalid values', () => { + expect(extendDatemath('')).toBeUndefined(); + }); + + it('Keeps `"now"` stable', () => { + expect(extendDatemath('now')).toEqual({ value: 'now' }); + expect(extendDatemath('now', 'before')).toEqual({ value: 'now' }); + expect(extendDatemath('now', 'after')).toEqual({ value: 'now' }); + }); + + describe('moving before', () => { + describe('with a negative operator', () => { + it('doubles miliseconds', () => { + expect(extendDatemath('now-250ms')).toEqual({ + value: 'now-500ms', + diffAmount: 250, + diffUnit: 'ms', + }); + }); + + it('normalizes miliseconds', () => { + expect(extendDatemath('now-500ms')).toEqual({ + value: 'now-1s', + diffAmount: 500, + diffUnit: 'ms', + }); + }); + + it('doubles seconds', () => { + expect(extendDatemath('now-10s')).toEqual({ + value: 'now-20s', + diffAmount: 10, + diffUnit: 's', + }); + }); + + it('normalizes seconds', () => { + expect(extendDatemath('now-30s')).toEqual({ + value: 'now-1m', + diffAmount: 30, + diffUnit: 's', + }); + }); + + it('doubles minutes when amount is low', () => { + expect(extendDatemath('now-1m')).toEqual({ value: 'now-2m', diffAmount: 1, diffUnit: 'm' }); + expect(extendDatemath('now-2m')).toEqual({ value: 'now-4m', diffAmount: 2, diffUnit: 'm' }); + expect(extendDatemath('now-3m')).toEqual({ value: 'now-6m', diffAmount: 3, diffUnit: 'm' }); + }); + + it('adds half the minutes when the amount is high', () => { + expect(extendDatemath('now-20m')).toEqual({ + value: 'now-30m', + diffAmount: 10, + diffUnit: 'm', + }); + }); + + it('Adds half an hour when the amount is one hour', () => { + expect(extendDatemath('now-1h')).toEqual({ + value: 'now-90m', + diffAmount: 30, + diffUnit: 'm', + }); + }); + + it('Adds one hour when the amount more than one hour', () => { + expect(extendDatemath('now-2h')).toEqual({ + value: 'now-3h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Adds one hour when the amount is one day', () => { + expect(extendDatemath('now-1d')).toEqual({ + value: 'now-25h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Adds one day when the amount is more than one day', () => { + expect(extendDatemath('now-2d')).toEqual({ + value: 'now-3d', + diffAmount: 1, + diffUnit: 'd', + }); + expect(extendDatemath('now-3d')).toEqual({ + value: 'now-4d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Adds one day when the amount is one week', () => { + expect(extendDatemath('now-1w')).toEqual({ + value: 'now-8d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Adds one week when the amount is more than one week', () => { + expect(extendDatemath('now-2w')).toEqual({ + value: 'now-3w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Adds one week when the amount is one month', () => { + expect(extendDatemath('now-1M')).toEqual({ + value: 'now-5w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Adds one month when the amount is more than one month', () => { + expect(extendDatemath('now-2M')).toEqual({ + value: 'now-3M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one month when the amount is one year', () => { + expect(extendDatemath('now-1y')).toEqual({ + value: 'now-13M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one year when the amount is in years', () => { + expect(extendDatemath('now-2y')).toEqual({ + value: 'now-3y', + diffAmount: 1, + diffUnit: 'y', + }); + }); + }); + + describe('with a positive Operator', () => { + it('Halves miliseconds', () => { + expect(extendDatemath('now+250ms')).toEqual({ + value: 'now+125ms', + diffAmount: 125, + diffUnit: 'ms', + }); + }); + + it('Halves seconds', () => { + expect(extendDatemath('now+10s')).toEqual({ + value: 'now+5s', + diffAmount: 5, + diffUnit: 's', + }); + }); + + it('Halves minutes when the amount is low', () => { + expect(extendDatemath('now+2m')).toEqual({ value: 'now+1m', diffAmount: 1, diffUnit: 'm' }); + expect(extendDatemath('now+4m')).toEqual({ value: 'now+2m', diffAmount: 2, diffUnit: 'm' }); + expect(extendDatemath('now+6m')).toEqual({ value: 'now+3m', diffAmount: 3, diffUnit: 'm' }); + }); + + it('Decreases minutes in half ammounts when the amount is high', () => { + expect(extendDatemath('now+30m')).toEqual({ + value: 'now+20m', + diffAmount: 10, + diffUnit: 'm', + }); + }); + + it('Decreases half an hour when the amount is one hour', () => { + expect(extendDatemath('now+1h')).toEqual({ + value: 'now+30m', + diffAmount: 30, + diffUnit: 'm', + }); + }); + + it('Removes one hour when the amount is one day', () => { + expect(extendDatemath('now+1d')).toEqual({ + value: 'now+23h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Removes one day when the amount is more than one day', () => { + expect(extendDatemath('now+2d')).toEqual({ + value: 'now+1d', + diffAmount: 1, + diffUnit: 'd', + }); + expect(extendDatemath('now+3d')).toEqual({ + value: 'now+2d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Removes one day when the amount is one week', () => { + expect(extendDatemath('now+1w')).toEqual({ + value: 'now+6d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Removes one week when the amount is more than one week', () => { + expect(extendDatemath('now+2w')).toEqual({ + value: 'now+1w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Removes one week when the amount is one month', () => { + expect(extendDatemath('now+1M')).toEqual({ + value: 'now+3w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Removes one month when the amount is more than one month', () => { + expect(extendDatemath('now+2M')).toEqual({ + value: 'now+1M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Removes one month when the amount is one year', () => { + expect(extendDatemath('now+1y')).toEqual({ + value: 'now+11M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one year when the amount is in years', () => { + expect(extendDatemath('now+2y')).toEqual({ + value: 'now+1y', + diffAmount: 1, + diffUnit: 'y', + }); + }); + }); + }); +}); + +describe('convertDate()', () => { + it('returns same value if units are the same', () => { + expect(convertDate(1, 'h', 'h')).toEqual(1); + }); + + it('converts from big units to small units', () => { + expect(convertDate(1, 's', 'ms')).toEqual(1000); + expect(convertDate(1, 'm', 'ms')).toEqual(60000); + expect(convertDate(1, 'h', 'ms')).toEqual(3600000); + expect(convertDate(1, 'd', 'ms')).toEqual(86400000); + expect(convertDate(1, 'M', 'ms')).toEqual(2592000000); + expect(convertDate(1, 'y', 'ms')).toEqual(31536000000); + }); + + it('converts from small units to big units', () => { + expect(convertDate(1000, 'ms', 's')).toEqual(1); + expect(convertDate(60000, 'ms', 'm')).toEqual(1); + expect(convertDate(3600000, 'ms', 'h')).toEqual(1); + expect(convertDate(86400000, 'ms', 'd')).toEqual(1); + expect(convertDate(2592000000, 'ms', 'M')).toEqual(1); + expect(convertDate(31536000000, 'ms', 'y')).toEqual(1); + }); + + it('Handles days to years', () => { + expect(convertDate(1, 'y', 'd')).toEqual(365); + expect(convertDate(365, 'd', 'y')).toEqual(1); + }); + + it('Handles years to months', () => { + expect(convertDate(1, 'y', 'M')).toEqual(12); + expect(convertDate(12, 'M', 'y')).toEqual(1); + }); + + it('Handles days to months', () => { + expect(convertDate(1, 'M', 'd')).toEqual(30); + expect(convertDate(30, 'd', 'M')).toEqual(1); + }); + + it('Handles days to weeks', () => { + expect(convertDate(1, 'w', 'd')).toEqual(7); + expect(convertDate(7, 'd', 'w')).toEqual(1); + }); + + it('Handles weeks to years', () => { + expect(convertDate(1, 'y', 'w')).toEqual(52); + expect(convertDate(52, 'w', 'y')).toEqual(1); + }); +}); + +describe('normalizeDate()', () => { + it('keeps units under the conversion ratio the same', () => { + expect(normalizeDate(999, 'ms')).toEqual({ amount: 999, unit: 'ms' }); + expect(normalizeDate(59, 's')).toEqual({ amount: 59, unit: 's' }); + expect(normalizeDate(59, 'm')).toEqual({ amount: 59, unit: 'm' }); + expect(normalizeDate(23, 'h')).toEqual({ amount: 23, unit: 'h' }); + expect(normalizeDate(6, 'd')).toEqual({ amount: 6, unit: 'd' }); + expect(normalizeDate(3, 'w')).toEqual({ amount: 3, unit: 'w' }); + expect(normalizeDate(11, 'M')).toEqual({ amount: 11, unit: 'M' }); + }); + + it('Moves to the next unit for values equal to the conversion ratio', () => { + expect(normalizeDate(1000, 'ms')).toEqual({ amount: 1, unit: 's' }); + expect(normalizeDate(60, 's')).toEqual({ amount: 1, unit: 'm' }); + expect(normalizeDate(60, 'm')).toEqual({ amount: 1, unit: 'h' }); + expect(normalizeDate(24, 'h')).toEqual({ amount: 1, unit: 'd' }); + expect(normalizeDate(7, 'd')).toEqual({ amount: 1, unit: 'w' }); + expect(normalizeDate(4, 'w')).toEqual({ amount: 1, unit: 'M' }); + expect(normalizeDate(12, 'M')).toEqual({ amount: 1, unit: 'y' }); + }); + + it('keeps units slightly over the conversion ratio the same', () => { + expect(normalizeDate(1001, 'ms')).toEqual({ amount: 1001, unit: 'ms' }); + expect(normalizeDate(61, 's')).toEqual({ amount: 61, unit: 's' }); + expect(normalizeDate(61, 'm')).toEqual({ amount: 61, unit: 'm' }); + expect(normalizeDate(25, 'h')).toEqual({ amount: 25, unit: 'h' }); + expect(normalizeDate(8, 'd')).toEqual({ amount: 8, unit: 'd' }); + expect(normalizeDate(5, 'w')).toEqual({ amount: 5, unit: 'w' }); + expect(normalizeDate(13, 'M')).toEqual({ amount: 13, unit: 'M' }); + }); + + it('moves to the next unit for any value higher than twice the conversion ratio', () => { + expect(normalizeDate(2001, 'ms')).toEqual({ amount: 2, unit: 's' }); + expect(normalizeDate(121, 's')).toEqual({ amount: 2, unit: 'm' }); + expect(normalizeDate(121, 'm')).toEqual({ amount: 2, unit: 'h' }); + expect(normalizeDate(49, 'h')).toEqual({ amount: 2, unit: 'd' }); + expect(normalizeDate(15, 'd')).toEqual({ amount: 2, unit: 'w' }); + expect(normalizeDate(9, 'w')).toEqual({ amount: 2, unit: 'M' }); + expect(normalizeDate(25, 'M')).toEqual({ amount: 2, unit: 'y' }); + }); +}); diff --git a/x-pack/plugins/infra/public/utils/datemath.ts b/x-pack/plugins/infra/public/utils/datemath.ts new file mode 100644 index 0000000000000..50a9b6e4f6945 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/datemath.ts @@ -0,0 +1,266 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import dateMath, { Unit } from '@elastic/datemath'; + +export function isValidDatemath(value: string): boolean { + const parsedValue = dateMath.parse(value); + return !!(parsedValue && parsedValue.isValid()); +} + +export function datemathToEpochMillis(value: string, round: 'down' | 'up' = 'down'): number | null { + const parsedValue = dateMath.parse(value, { roundUp: round === 'up' }); + if (!parsedValue || !parsedValue.isValid()) { + return null; + } + return parsedValue.valueOf(); +} + +type DatemathExtension = + | { + value: string; + diffUnit: Unit; + diffAmount: number; + } + | { value: 'now' }; + +const datemathNowExpression = /(\+|\-)(\d+)(ms|s|m|h|d|w|M|y)$/; + +/** + * Extend a datemath value + * @param value The value to extend + * @param {'before' | 'after'} direction Should the value move before or after in time + * @param oppositeEdge For absolute values, the value of the other edge of the range + */ +export function extendDatemath( + value: string, + direction: 'before' | 'after' = 'before', + oppositeEdge?: string +): DatemathExtension | undefined { + if (!isValidDatemath(value)) { + return undefined; + } + + // `now` cannot be extended + if (value === 'now') { + return { value: 'now' }; + } + + // The unit is relative + if (value.startsWith('now')) { + return extendRelativeDatemath(value, direction); + } else if (oppositeEdge && isValidDatemath(oppositeEdge)) { + return extendAbsoluteDatemath(value, direction, oppositeEdge); + } + + return undefined; +} + +function extendRelativeDatemath( + value: string, + direction: 'before' | 'after' +): DatemathExtension | undefined { + const [, operator, amount, unit] = datemathNowExpression.exec(value) || []; + if (!operator || !amount || !unit) { + return undefined; + } + + const mustIncreaseAmount = operator === '-' && direction === 'before'; + const parsedAmount = parseInt(amount, 10); + let newUnit: Unit = unit as Unit; + let newAmount: number; + + // Extend the amount + switch (unit) { + // For small units, always double or halve the amount + case 'ms': + case 's': + newAmount = mustIncreaseAmount ? parsedAmount * 2 : Math.floor(parsedAmount / 2); + break; + // For minutes, increase or decrease in doubles or halves, depending on + // the amount of minutes + case 'm': + let ratio; + const MINUTES_LARGE = 10; + if (mustIncreaseAmount) { + ratio = parsedAmount >= MINUTES_LARGE ? 0.5 : 1; + newAmount = parsedAmount + Math.floor(parsedAmount * ratio); + } else { + newAmount = + parsedAmount >= MINUTES_LARGE + ? Math.floor(parsedAmount / 1.5) + : parsedAmount - Math.floor(parsedAmount * 0.5); + } + break; + + // For hours, increase or decrease half an hour for 1 hour. Otherwise + // increase full hours + case 'h': + if (parsedAmount === 1) { + newAmount = mustIncreaseAmount ? 90 : 30; + newUnit = 'm'; + } else { + newAmount = mustIncreaseAmount ? parsedAmount + 1 : parsedAmount - 1; + } + break; + + // For the rest of units, increase or decrease one smaller unit for + // amounts of 1. Otherwise increase or decrease the unit + case 'd': + case 'w': + case 'M': + case 'y': + if (parsedAmount === 1) { + newUnit = dateMath.unitsDesc[dateMath.unitsDesc.indexOf(unit) + 1]; + newAmount = mustIncreaseAmount + ? convertDate(1, unit, newUnit) + 1 + : convertDate(1, unit, newUnit) - 1; + } else { + newAmount = mustIncreaseAmount ? parsedAmount + 1 : parsedAmount - 1; + } + break; + + default: + throw new TypeError('Unhandled datemath unit'); + } + + // normalize amount and unit (i.e. 120s -> 2m) + const { unit: normalizedUnit, amount: normalizedAmount } = normalizeDate(newAmount, newUnit); + + // How much have we changed the time? + const diffAmount = Math.abs(normalizedAmount - convertDate(parsedAmount, unit, normalizedUnit)); + // if `diffAmount` is not an integer after normalization, express the difference in the original unit + const shouldKeepDiffUnit = diffAmount % 1 !== 0; + + return { + value: `now${operator}${normalizedAmount}${normalizedUnit}`, + diffUnit: shouldKeepDiffUnit ? unit : newUnit, + diffAmount: shouldKeepDiffUnit ? Math.abs(newAmount - parsedAmount) : diffAmount, + }; +} + +function extendAbsoluteDatemath( + value: string, + direction: 'before' | 'after', + oppositeEdge: string +): DatemathExtension { + const valueTimestamp = datemathToEpochMillis(value)!; + const oppositeEdgeTimestamp = datemathToEpochMillis(oppositeEdge)!; + const actualTimestampDiff = Math.abs(valueTimestamp - oppositeEdgeTimestamp); + const normalizedDiff = normalizeDate(actualTimestampDiff, 'ms'); + const normalizedTimestampDiff = convertDate(normalizedDiff.amount, normalizedDiff.unit, 'ms'); + + const newValue = + direction === 'before' + ? valueTimestamp - normalizedTimestampDiff + : valueTimestamp + normalizedTimestampDiff; + + return { + value: new Date(newValue).toISOString(), + diffUnit: normalizedDiff.unit, + diffAmount: normalizedDiff.amount, + }; +} + +const CONVERSION_RATIOS: Record> = { + wy: [ + ['w', 52], // 1 year = 52 weeks + ['y', 1], + ], + w: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 7], // 1 week = 7 days + ['w', 4], // 1 month = 4 weeks = 28 days + ['M', 12], // 1 year = 12 months = 52 weeks = 364 days + ['y', 1], + ], + M: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 30], // 1 month = 30 days + ['M', 12], // 1 year = 12 months = 360 days + ['y', 1], + ], + default: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 365], // 1 year = 365 days + ['y', 1], + ], +}; + +function getRatioScale(from: Unit, to?: Unit) { + if ((from === 'y' && to === 'w') || (from === 'w' && to === 'y')) { + return CONVERSION_RATIOS.wy; + } else if (from === 'w' || to === 'w') { + return CONVERSION_RATIOS.w; + } else if (from === 'M' || to === 'M') { + return CONVERSION_RATIOS.M; + } else { + return CONVERSION_RATIOS.default; + } +} + +export function convertDate(value: number, from: Unit, to: Unit): number { + if (from === to) { + return value; + } + + const ratioScale = getRatioScale(from, to); + const fromIdx = ratioScale.findIndex(ratio => ratio[0] === from); + const toIdx = ratioScale.findIndex(ratio => ratio[0] === to); + + let convertedValue = value; + + if (fromIdx > toIdx) { + // `from` is the bigger unit. Multiply the value + for (let i = toIdx; i < fromIdx; i++) { + convertedValue *= ratioScale[i][1]; + } + } else { + // `from` is the smaller unit. Divide the value + for (let i = fromIdx; i < toIdx; i++) { + convertedValue /= ratioScale[i][1]; + } + } + + return convertedValue; +} + +export function normalizeDate(amount: number, unit: Unit): { amount: number; unit: Unit } { + // There is nothing after years + if (unit === 'y') { + return { amount, unit }; + } + + const nextUnit = dateMath.unitsAsc[dateMath.unitsAsc.indexOf(unit) + 1]; + const ratioScale = getRatioScale(unit, nextUnit); + const ratio = ratioScale.find(r => r[0] === unit)![1]; + + const newAmount = amount / ratio; + + // Exact conversion + if (newAmount === 1) { + return { amount: newAmount, unit: nextUnit }; + } + + // Might be able to go one unit more, so try again, rounding the value + // 7200s => 120m => 2h + // 7249s ~> 120m ~> 2h + if (newAmount >= 2) { + return normalizeDate(Math.round(newAmount), nextUnit); + } + + // Cannot go one one unit above. Return as it is + return { amount, unit }; +} diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts index be6b8c40753ae..bb528ee5b18c5 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts @@ -8,23 +8,26 @@ import { bisector } from 'd3-array'; import { compareToTimeKey, getIndexAtTimeKey, TimeKey, UniqueTimeKey } from '../../../common/time'; import { InfraLogEntryFields } from '../../graphql/types'; - -export type LogEntry = InfraLogEntryFields.Fragment; - -export type LogEntryColumn = InfraLogEntryFields.Columns; -export type LogEntryMessageColumn = InfraLogEntryFields.InfraLogEntryMessageColumnInlineFragment; -export type LogEntryTimestampColumn = InfraLogEntryFields.InfraLogEntryTimestampColumnInlineFragment; -export type LogEntryFieldColumn = InfraLogEntryFields.InfraLogEntryFieldColumnInlineFragment; +import { + LogEntry, + LogColumn, + LogTimestampColumn, + LogFieldColumn, + LogMessageColumn, + LogMessagePart, + LogMessageFieldPart, + LogMessageConstantPart, +} from '../../../common/http_api'; export type LogEntryMessageSegment = InfraLogEntryFields.Message; export type LogEntryConstantMessageSegment = InfraLogEntryFields.InfraLogMessageConstantSegmentInlineFragment; export type LogEntryFieldMessageSegment = InfraLogEntryFields.InfraLogMessageFieldSegmentInlineFragment; -export const getLogEntryKey = (entry: { key: TimeKey }) => entry.key; +export const getLogEntryKey = (entry: { cursor: TimeKey }) => entry.cursor; -export const getUniqueLogEntryKey = (entry: { gid: string; key: TimeKey }): UniqueTimeKey => ({ - ...entry.key, - gid: entry.gid, +export const getUniqueLogEntryKey = (entry: { id: string; cursor: TimeKey }): UniqueTimeKey => ({ + ...entry.cursor, + gid: entry.id, }); const logEntryTimeBisector = bisector(compareToTimeKey(getLogEntryKey)); @@ -39,19 +42,17 @@ export const getLogEntryAtTime = (entries: LogEntry[], time: TimeKey) => { return entryIndex !== null ? entries[entryIndex] : null; }; -export const isTimestampColumn = (column: LogEntryColumn): column is LogEntryTimestampColumn => +export const isTimestampColumn = (column: LogColumn): column is LogTimestampColumn => column != null && 'timestamp' in column; -export const isMessageColumn = (column: LogEntryColumn): column is LogEntryMessageColumn => +export const isMessageColumn = (column: LogColumn): column is LogMessageColumn => column != null && 'message' in column; -export const isFieldColumn = (column: LogEntryColumn): column is LogEntryFieldColumn => +export const isFieldColumn = (column: LogColumn): column is LogFieldColumn => column != null && 'field' in column; -export const isConstantSegment = ( - segment: LogEntryMessageSegment -): segment is LogEntryConstantMessageSegment => 'constant' in segment; +export const isConstantSegment = (segment: LogMessagePart): segment is LogMessageConstantPart => + 'constant' in segment; -export const isFieldSegment = ( - segment: LogEntryMessageSegment -): segment is LogEntryFieldMessageSegment => 'field' in segment && 'value' in segment; +export const isFieldSegment = (segment: LogMessagePart): segment is LogMessageFieldPart => + 'field' in segment && 'value' in segment; diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts index 3361faa23a124..abb004911214b 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts @@ -5,8 +5,14 @@ */ import { InfraLogEntryHighlightFields } from '../../graphql/types'; - -export type LogEntryHighlight = InfraLogEntryHighlightFields.Fragment; +import { + LogEntry, + LogColumn, + LogMessageColumn, + LogFieldColumn, + LogMessagePart, + LogMessageFieldPart, +} from '../../../common/http_api'; export type LogEntryHighlightColumn = InfraLogEntryHighlightFields.Columns; export type LogEntryHighlightMessageColumn = InfraLogEntryHighlightFields.InfraLogEntryMessageColumnInlineFragment; @@ -16,18 +22,14 @@ export type LogEntryHighlightMessageSegment = InfraLogEntryHighlightFields.Messa export type LogEntryHighlightFieldMessageSegment = InfraLogEntryHighlightFields.InfraLogMessageFieldSegmentInlineFragment; export interface LogEntryHighlightsMap { - [entryId: string]: LogEntryHighlight[]; + [entryId: string]: LogEntry[]; } -export const isHighlightMessageColumn = ( - column: LogEntryHighlightColumn -): column is LogEntryHighlightMessageColumn => column != null && 'message' in column; +export const isHighlightMessageColumn = (column: LogColumn): column is LogMessageColumn => + column != null && 'message' in column; -export const isHighlightFieldColumn = ( - column: LogEntryHighlightColumn -): column is LogEntryHighlightFieldColumn => column != null && 'field' in column; +export const isHighlightFieldColumn = (column: LogColumn): column is LogFieldColumn => + column != null && 'field' in column; -export const isHighlightFieldSegment = ( - segment: LogEntryHighlightMessageSegment -): segment is LogEntryHighlightFieldMessageSegment => +export const isHighlightFieldSegment = (segment: LogMessagePart): segment is LogMessageFieldPart => segment && 'field' in segment && 'highlights' in segment; diff --git a/x-pack/plugins/infra/server/graphql/index.ts b/x-pack/plugins/infra/server/graphql/index.ts index 82fef41db1a73..f5150972a3a65 100644 --- a/x-pack/plugins/infra/server/graphql/index.ts +++ b/x-pack/plugins/infra/server/graphql/index.ts @@ -6,14 +6,7 @@ import { rootSchema } from '../../common/graphql/root/schema.gql'; import { sharedSchema } from '../../common/graphql/shared/schema.gql'; -import { logEntriesSchema } from './log_entries/schema.gql'; import { sourceStatusSchema } from './source_status/schema.gql'; import { sourcesSchema } from './sources/schema.gql'; -export const schemas = [ - rootSchema, - sharedSchema, - logEntriesSchema, - sourcesSchema, - sourceStatusSchema, -]; +export const schemas = [rootSchema, sharedSchema, sourcesSchema, sourceStatusSchema]; diff --git a/x-pack/plugins/infra/server/graphql/log_entries/index.ts b/x-pack/plugins/infra/server/graphql/log_entries/index.ts deleted file mode 100644 index 21134862663ec..0000000000000 --- a/x-pack/plugins/infra/server/graphql/log_entries/index.ts +++ /dev/null @@ -1,7 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -export { createLogEntriesResolvers } from './resolvers'; diff --git a/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts b/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts deleted file mode 100644 index edbb736b2c4fd..0000000000000 --- a/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts +++ /dev/null @@ -1,175 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - InfraLogEntryColumn, - InfraLogEntryFieldColumn, - InfraLogEntryMessageColumn, - InfraLogEntryTimestampColumn, - InfraLogMessageConstantSegment, - InfraLogMessageFieldSegment, - InfraLogMessageSegment, - InfraSourceResolvers, -} from '../../graphql/types'; -import { InfraLogEntriesDomain } from '../../lib/domains/log_entries_domain'; -import { parseFilterQuery } from '../../utils/serialized_query'; -import { ChildResolverOf, InfraResolverOf } from '../../utils/typed_resolvers'; -import { QuerySourceResolver } from '../sources/resolvers'; - -export type InfraSourceLogEntriesAroundResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export type InfraSourceLogEntriesBetweenResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export type InfraSourceLogEntryHighlightsResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export const createLogEntriesResolvers = (libs: { - logEntries: InfraLogEntriesDomain; -}): { - InfraSource: { - logEntriesAround: InfraSourceLogEntriesAroundResolver; - logEntriesBetween: InfraSourceLogEntriesBetweenResolver; - logEntryHighlights: InfraSourceLogEntryHighlightsResolver; - }; - InfraLogEntryColumn: { - __resolveType( - logEntryColumn: InfraLogEntryColumn - ): - | 'InfraLogEntryTimestampColumn' - | 'InfraLogEntryMessageColumn' - | 'InfraLogEntryFieldColumn' - | null; - }; - InfraLogMessageSegment: { - __resolveType( - messageSegment: InfraLogMessageSegment - ): 'InfraLogMessageFieldSegment' | 'InfraLogMessageConstantSegment' | null; - }; -} => ({ - InfraSource: { - async logEntriesAround(source, args, { req }) { - const countBefore = args.countBefore || 0; - const countAfter = args.countAfter || 0; - - const { entriesBefore, entriesAfter } = await libs.logEntries.getLogEntriesAround( - req, - source.id, - args.key, - countBefore + 1, - countAfter + 1, - parseFilterQuery(args.filterQuery) - ); - - const hasMoreBefore = entriesBefore.length > countBefore; - const hasMoreAfter = entriesAfter.length > countAfter; - - const entries = [ - ...(hasMoreBefore ? entriesBefore.slice(1) : entriesBefore), - ...(hasMoreAfter ? entriesAfter.slice(0, -1) : entriesAfter), - ]; - - return { - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore, - hasMoreAfter, - filterQuery: args.filterQuery, - entries, - }; - }, - async logEntriesBetween(source, args, { req }) { - const entries = await libs.logEntries.getLogEntriesBetween( - req, - source.id, - args.startKey, - args.endKey, - parseFilterQuery(args.filterQuery) - ); - - return { - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore: true, - hasMoreAfter: true, - filterQuery: args.filterQuery, - entries, - }; - }, - async logEntryHighlights(source, args, { req }) { - const highlightedLogEntrySets = await libs.logEntries.getLogEntryHighlights( - req, - source.id, - args.startKey, - args.endKey, - args.highlights.filter(highlightInput => !!highlightInput.query), - parseFilterQuery(args.filterQuery) - ); - - return highlightedLogEntrySets.map(entries => ({ - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore: true, - hasMoreAfter: true, - filterQuery: args.filterQuery, - entries, - })); - }, - }, - InfraLogEntryColumn: { - __resolveType(logEntryColumn) { - if (isTimestampColumn(logEntryColumn)) { - return 'InfraLogEntryTimestampColumn'; - } - - if (isMessageColumn(logEntryColumn)) { - return 'InfraLogEntryMessageColumn'; - } - - if (isFieldColumn(logEntryColumn)) { - return 'InfraLogEntryFieldColumn'; - } - - return null; - }, - }, - InfraLogMessageSegment: { - __resolveType(messageSegment) { - if (isConstantSegment(messageSegment)) { - return 'InfraLogMessageConstantSegment'; - } - - if (isFieldSegment(messageSegment)) { - return 'InfraLogMessageFieldSegment'; - } - - return null; - }, - }, -}); - -const isTimestampColumn = (column: InfraLogEntryColumn): column is InfraLogEntryTimestampColumn => - 'timestamp' in column; - -const isMessageColumn = (column: InfraLogEntryColumn): column is InfraLogEntryMessageColumn => - 'message' in column; - -const isFieldColumn = (column: InfraLogEntryColumn): column is InfraLogEntryFieldColumn => - 'field' in column && 'value' in column; - -const isConstantSegment = ( - segment: InfraLogMessageSegment -): segment is InfraLogMessageConstantSegment => 'constant' in segment; - -const isFieldSegment = (segment: InfraLogMessageSegment): segment is InfraLogMessageFieldSegment => - 'field' in segment && 'value' in segment && 'highlights' in segment; diff --git a/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts b/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts deleted file mode 100644 index 945f2f85435e5..0000000000000 --- a/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts +++ /dev/null @@ -1,136 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -export const logEntriesSchema = gql` - "A segment of the log entry message that was derived from a field" - type InfraLogMessageFieldSegment { - "The field the segment was derived from" - field: String! - "The segment's message" - value: String! - "A list of highlighted substrings of the value" - highlights: [String!]! - } - - "A segment of the log entry message that was derived from a string literal" - type InfraLogMessageConstantSegment { - "The segment's message" - constant: String! - } - - "A segment of the log entry message" - union InfraLogMessageSegment = InfraLogMessageFieldSegment | InfraLogMessageConstantSegment - - "A special built-in column that contains the log entry's timestamp" - type InfraLogEntryTimestampColumn { - "The id of the corresponding column configuration" - columnId: ID! - "The timestamp" - timestamp: Float! - } - - "A special built-in column that contains the log entry's constructed message" - type InfraLogEntryMessageColumn { - "The id of the corresponding column configuration" - columnId: ID! - "A list of the formatted log entry segments" - message: [InfraLogMessageSegment!]! - } - - "A column that contains the value of a field of the log entry" - type InfraLogEntryFieldColumn { - "The id of the corresponding column configuration" - columnId: ID! - "The field name of the column" - field: String! - "The value of the field in the log entry" - value: String! - "A list of highlighted substrings of the value" - highlights: [String!]! - } - - "A column of a log entry" - union InfraLogEntryColumn = - InfraLogEntryTimestampColumn - | InfraLogEntryMessageColumn - | InfraLogEntryFieldColumn - - "A log entry" - type InfraLogEntry { - "A unique representation of the log entry's position in the event stream" - key: InfraTimeKey! - "The log entry's id" - gid: String! - "The source id" - source: String! - "The columns used for rendering the log entry" - columns: [InfraLogEntryColumn!]! - } - - "A highlighting definition" - input InfraLogEntryHighlightInput { - "The query to highlight by" - query: String! - "The number of highlighted documents to include beyond the beginning of the interval" - countBefore: Int! - "The number of highlighted documents to include beyond the end of the interval" - countAfter: Int! - } - - "A consecutive sequence of log entries" - type InfraLogEntryInterval { - "The key corresponding to the start of the interval covered by the entries" - start: InfraTimeKey - "The key corresponding to the end of the interval covered by the entries" - end: InfraTimeKey - "Whether there are more log entries available before the start" - hasMoreBefore: Boolean! - "Whether there are more log entries available after the end" - hasMoreAfter: Boolean! - "The query the log entries were filtered by" - filterQuery: String - "The query the log entries were highlighted with" - highlightQuery: String - "A list of the log entries" - entries: [InfraLogEntry!]! - } - - extend type InfraSource { - "A consecutive span of log entries surrounding a point in time" - logEntriesAround( - "The sort key that corresponds to the point in time" - key: InfraTimeKeyInput! - "The maximum number of preceding to return" - countBefore: Int = 0 - "The maximum number of following to return" - countAfter: Int = 0 - "The query to filter the log entries by" - filterQuery: String - ): InfraLogEntryInterval! - "A consecutive span of log entries within an interval" - logEntriesBetween( - "The sort key that corresponds to the start of the interval" - startKey: InfraTimeKeyInput! - "The sort key that corresponds to the end of the interval" - endKey: InfraTimeKeyInput! - "The query to filter the log entries by" - filterQuery: String - ): InfraLogEntryInterval! - "Sequences of log entries matching sets of highlighting queries within an interval" - logEntryHighlights( - "The sort key that corresponds to the start of the interval" - startKey: InfraTimeKeyInput! - "The sort key that corresponds to the end of the interval" - endKey: InfraTimeKeyInput! - "The query to filter the log entries by" - filterQuery: String - "The highlighting to apply to the log entries" - highlights: [InfraLogEntryHighlightInput!]! - ): [InfraLogEntryInterval!]! - } -`; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index f058b9e52c75b..fb9dd172bf6ed 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -7,7 +7,6 @@ import { IResolvers, makeExecutableSchema } from 'graphql-tools'; import { initIpToHostName } from './routes/ip_to_hostname'; import { schemas } from './graphql'; -import { createLogEntriesResolvers } from './graphql/log_entries'; import { createSourceStatusResolvers } from './graphql/source_status'; import { createSourcesResolvers } from './graphql/sources'; import { InfraBackendLibs } from './lib/infra_types'; @@ -34,7 +33,6 @@ import { initInventoryMetaRoute } from './routes/inventory_metadata'; export const initInfraServer = (libs: InfraBackendLibs) => { const schema = makeExecutableSchema({ resolvers: [ - createLogEntriesResolvers(libs) as IResolvers, createSourcesResolvers(libs) as IResolvers, createSourceStatusResolvers(libs) as IResolvers, ], diff --git a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts index f48c949329b04..3a5dff75f004e 100644 --- a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts @@ -8,12 +8,11 @@ import { timeMilliseconds } from 'd3-time'; import * as runtimeTypes from 'io-ts'; -import { compact, first, get, has, zip } from 'lodash'; +import { compact, first, get, has } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; import { identity, constant } from 'fp-ts/lib/function'; import { RequestHandlerContext } from 'src/core/server'; -import { compareTimeKeys, isTimeKey, TimeKey } from '../../../../common/time'; import { JsonObject, JsonValue } from '../../../../common/typed_json'; import { LogEntriesAdapter, @@ -27,8 +26,6 @@ import { InfraSourceConfiguration } from '../../sources'; import { SortedSearchHit } from '../framework'; import { KibanaFramework } from '../framework/kibana_framework_adapter'; -const DAY_MILLIS = 24 * 60 * 60 * 1000; -const LOOKUP_OFFSETS = [0, 1, 7, 30, 365, 10000, Infinity].map(days => days * DAY_MILLIS); const TIMESTAMP_FORMAT = 'epoch_millis'; interface LogItemHit { @@ -41,53 +38,13 @@ interface LogItemHit { export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { constructor(private readonly framework: KibanaFramework) {} - public async getAdjacentLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - direction: 'asc' | 'desc', - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - if (maxCount <= 0) { - return []; - } - - const intervals = getLookupIntervals(start.time, direction); - - let documents: LogEntryDocument[] = []; - for (const [intervalStart, intervalEnd] of intervals) { - if (documents.length >= maxCount) { - break; - } - - const documentsInInterval = await this.getLogEntryDocumentsBetween( - requestContext, - sourceConfiguration, - fields, - intervalStart, - intervalEnd, - documents.length > 0 ? documents[documents.length - 1].key : start, - maxCount - documents.length, - filterQuery, - highlightQuery - ); - - documents = [...documents, ...documentsInInterval]; - } - - return direction === 'asc' ? documents : documents.reverse(); - } - public async getLogEntries( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, fields: string[], params: LogEntriesParams ): Promise { - const { startDate, endDate, query, cursor, size, highlightTerm } = params; + const { startTimestamp, endTimestamp, query, cursor, size, highlightTerm } = params; const { sortDirection, searchAfterClause } = processCursor(cursor); @@ -133,8 +90,8 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { { range: { [sourceConfiguration.fields.timestamp]: { - gte: startDate, - lte: endDate, + gte: startTimestamp, + lte: endTimestamp, format: TIMESTAMP_FORMAT, }, }, @@ -158,40 +115,19 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { return mapHitsToLogEntryDocuments(hits, sourceConfiguration.fields.timestamp, fields); } - /** @deprecated */ - public async getContainedLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - end: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - const documents = await this.getLogEntryDocumentsBetween( - requestContext, - sourceConfiguration, - fields, - start.time, - end.time, - start, - 10000, - filterQuery, - highlightQuery - ); - - return documents.filter(document => compareTimeKeys(document.key, end) < 0); - } - public async getContainedLogSummaryBuckets( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, filterQuery?: LogEntryQuery ): Promise { - const bucketIntervalStarts = timeMilliseconds(new Date(start), new Date(end), bucketSize); + const bucketIntervalStarts = timeMilliseconds( + new Date(startTimestamp), + new Date(endTimestamp), + bucketSize + ); const query = { allowNoIndices: true, @@ -229,8 +165,8 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { { range: { [sourceConfiguration.fields.timestamp]: { - gte: start, - lte: end, + gte: startTimestamp, + lte: endTimestamp, format: TIMESTAMP_FORMAT, }, }, @@ -288,112 +224,6 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { } return document; } - - private async getLogEntryDocumentsBetween( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: number, - end: number, - after: TimeKey | null, - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - if (maxCount <= 0) { - return []; - } - - const sortDirection: 'asc' | 'desc' = start <= end ? 'asc' : 'desc'; - - const startRange = { - [sortDirection === 'asc' ? 'gte' : 'lte']: start, - }; - const endRange = - end === Infinity - ? {} - : { - [sortDirection === 'asc' ? 'lte' : 'gte']: end, - }; - - const highlightClause = highlightQuery - ? { - highlight: { - boundary_scanner: 'word', - fields: fields.reduce( - (highlightFieldConfigs, fieldName) => ({ - ...highlightFieldConfigs, - [fieldName]: {}, - }), - {} - ), - fragment_size: 1, - number_of_fragments: 100, - post_tags: [''], - pre_tags: [''], - highlight_query: highlightQuery, - }, - } - : {}; - - const searchAfterClause = isTimeKey(after) - ? { - search_after: [after.time, after.tiebreaker], - } - : {}; - - const query = { - allowNoIndices: true, - index: sourceConfiguration.logAlias, - ignoreUnavailable: true, - body: { - query: { - bool: { - filter: [ - ...createQueryFilterClauses(filterQuery), - { - range: { - [sourceConfiguration.fields.timestamp]: { - ...startRange, - ...endRange, - format: TIMESTAMP_FORMAT, - }, - }, - }, - ], - }, - }, - ...highlightClause, - ...searchAfterClause, - _source: fields, - size: maxCount, - sort: [ - { [sourceConfiguration.fields.timestamp]: sortDirection }, - { [sourceConfiguration.fields.tiebreaker]: sortDirection }, - ], - track_total_hits: false, - }, - }; - - const response = await this.framework.callWithRequest( - requestContext, - 'search', - query - ); - const hits = response.hits.hits; - const documents = hits.map(convertHitToLogEntryDocument(fields)); - - return documents; - } -} - -function getLookupIntervals(start: number, direction: 'asc' | 'desc'): Array<[number, number]> { - const offsetSign = direction === 'asc' ? 1 : -1; - const translatedOffsets = LOOKUP_OFFSETS.map(offset => start + offset * offsetSign); - const intervals = zip(translatedOffsets.slice(0, -1), translatedOffsets.slice(1)) as Array< - [number, number] - >; - return intervals; } function mapHitsToLogEntryDocuments( @@ -423,28 +253,6 @@ function mapHitsToLogEntryDocuments( }); } -/** @deprecated */ -const convertHitToLogEntryDocument = (fields: string[]) => ( - hit: SortedSearchHit -): LogEntryDocument => ({ - gid: hit._id, - fields: fields.reduce( - (flattenedFields, fieldName) => - has(hit._source, fieldName) - ? { - ...flattenedFields, - [fieldName]: get(hit._source, fieldName), - } - : flattenedFields, - {} as { [fieldName: string]: string | number | object | boolean | null } - ), - highlights: hit.highlight || {}, - key: { - time: hit.sort[0], - tiebreaker: hit.sort[1], - }, -}); - const convertDateRangeBucketToSummaryBucket = ( bucket: LogSummaryDateRangeBucket ): LogSummaryBucket => ({ diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 2f71d56e1e0e3..9a2631e3c2f76 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import stringify from 'json-stable-stringify'; import { sortBy } from 'lodash'; import { RequestHandlerContext } from 'src/core/server'; @@ -18,13 +17,10 @@ import { LogEntriesCursor, LogColumn, } from '../../../../common/http_api'; -import { InfraLogEntry, InfraLogMessageSegment } from '../../../graphql/types'; import { InfraSourceConfiguration, InfraSources, SavedSourceConfigurationFieldColumnRuntimeType, - SavedSourceConfigurationMessageColumnRuntimeType, - SavedSourceConfigurationTimestampColumnRuntimeType, } from '../../sources'; import { getBuiltinRules } from './builtin_rules'; import { convertDocumentSourceToLogItemFields } from './convert_document_source_to_log_item_fields'; @@ -36,16 +32,16 @@ import { } from './message'; export interface LogEntriesParams { - startDate: number; - endDate: number; + startTimestamp: number; + endTimestamp: number; size?: number; query?: JsonObject; cursor?: { before: LogEntriesCursor | 'last' } | { after: LogEntriesCursor | 'first' }; highlightTerm?: string; } export interface LogEntriesAroundParams { - startDate: number; - endDate: number; + startTimestamp: number; + endTimestamp: number; size?: number; center: LogEntriesCursor; query?: JsonObject; @@ -67,7 +63,7 @@ export class InfraLogEntriesDomain { sourceId: string, params: LogEntriesAroundParams ) { - const { startDate, endDate, center, query, size, highlightTerm } = params; + const { startTimestamp, endTimestamp, center, query, size, highlightTerm } = params; /* * For odd sizes we will round this value down for the first half, and up @@ -80,8 +76,8 @@ export class InfraLogEntriesDomain { const halfSize = (size || LOG_ENTRIES_PAGE_SIZE) / 2; const entriesBefore = await this.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query, cursor: { before: center }, size: Math.floor(halfSize), @@ -101,8 +97,8 @@ export class InfraLogEntriesDomain { : { time: center.time - 1, tiebreaker: 0 }; const entriesAfter = await this.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query, cursor: { after: cursorAfter }, size: Math.ceil(halfSize), @@ -112,71 +108,6 @@ export class InfraLogEntriesDomain { return [...entriesBefore, ...entriesAfter]; } - /** @deprecated */ - public async getLogEntriesAround( - requestContext: RequestHandlerContext, - sourceId: string, - key: TimeKey, - maxCountBefore: number, - maxCountAfter: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise<{ entriesBefore: InfraLogEntry[]; entriesAfter: InfraLogEntry[] }> { - if (maxCountBefore <= 0 && maxCountAfter <= 0) { - return { - entriesBefore: [], - entriesAfter: [], - }; - } - - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - - const documentsBefore = await this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - key, - 'desc', - Math.max(maxCountBefore, 1), - filterQuery, - highlightQuery - ); - const lastKeyBefore = - documentsBefore.length > 0 - ? documentsBefore[documentsBefore.length - 1].key - : { - time: key.time - 1, - tiebreaker: 0, - }; - - const documentsAfter = await this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - lastKeyBefore, - 'asc', - maxCountAfter, - filterQuery, - highlightQuery - ); - - return { - entriesBefore: (maxCountBefore > 0 ? documentsBefore : []).map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ), - entriesAfter: documentsAfter.map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ), - }; - } - public async getLogEntries( requestContext: RequestHandlerContext, sourceId: string, @@ -220,7 +151,7 @@ export class InfraLogEntriesDomain { return { columnId: column.fieldColumn.id, field: column.fieldColumn.field, - value: stringify(doc.fields[column.fieldColumn.field]), + value: doc.fields[column.fieldColumn.field], highlights: doc.highlights[column.fieldColumn.field] || [], }; } @@ -232,116 +163,6 @@ export class InfraLogEntriesDomain { return entries; } - /** @deprecated */ - public async getLogEntriesBetween( - requestContext: RequestHandlerContext, - sourceId: string, - startKey: TimeKey, - endKey: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - const documents = await this.adapter.getContainedLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - endKey, - filterQuery, - highlightQuery - ); - const entries = documents.map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ); - return entries; - } - - /** @deprecated */ - public async getLogEntryHighlights( - requestContext: RequestHandlerContext, - sourceId: string, - startKey: TimeKey, - endKey: TimeKey, - highlights: Array<{ - query: string; - countBefore: number; - countAfter: number; - }>, - filterQuery?: LogEntryQuery - ): Promise { - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - - const documentSets = await Promise.all( - highlights.map(async highlight => { - const highlightQuery = createHighlightQueryDsl(highlight.query, requiredFields); - const query = filterQuery - ? { - bool: { - filter: [filterQuery, highlightQuery], - }, - } - : highlightQuery; - const [documentsBefore, documents, documentsAfter] = await Promise.all([ - this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - 'desc', - highlight.countBefore, - query, - highlightQuery - ), - this.adapter.getContainedLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - endKey, - query, - highlightQuery - ), - this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - endKey, - 'asc', - highlight.countAfter, - query, - highlightQuery - ), - ]); - const entries = [...documentsBefore, ...documents, ...documentsAfter].map( - convertLogDocumentToEntry( - sourceId, - configuration.logColumns, - messageFormattingRules.format - ) - ); - - return entries; - }) - ); - - return documentSets; - } - public async getLogSummaryBucketsBetween( requestContext: RequestHandlerContext, sourceId: string, @@ -368,8 +189,8 @@ export class InfraLogEntriesDomain { public async getLogSummaryHighlightBucketsBetween( requestContext: RequestHandlerContext, sourceId: string, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, highlightQueries: string[], filterQuery?: LogEntryQuery @@ -396,8 +217,8 @@ export class InfraLogEntriesDomain { const summaryBuckets = await this.adapter.getContainedLogSummaryBuckets( requestContext, configuration, - start, - end, + startTimestamp, + endTimestamp, bucketSize, query ); @@ -445,17 +266,6 @@ interface LogItemHit { } export interface LogEntriesAdapter { - getAdjacentLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - direction: 'asc' | 'desc', - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise; - getLogEntries( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, @@ -463,21 +273,11 @@ export interface LogEntriesAdapter { params: LogEntriesParams ): Promise; - getContainedLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - end: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise; - getContainedLogSummaryBuckets( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, filterQuery?: LogEntryQuery ): Promise; @@ -505,37 +305,6 @@ export interface LogSummaryBucket { topEntryKeys: TimeKey[]; } -/** @deprecated */ -const convertLogDocumentToEntry = ( - sourceId: string, - logColumns: InfraSourceConfiguration['logColumns'], - formatLogMessage: (fields: Fields, highlights: Highlights) => InfraLogMessageSegment[] -) => (document: LogEntryDocument): InfraLogEntry => ({ - key: document.key, - gid: document.gid, - source: sourceId, - columns: logColumns.map(logColumn => { - if (SavedSourceConfigurationTimestampColumnRuntimeType.is(logColumn)) { - return { - columnId: logColumn.timestampColumn.id, - timestamp: document.key.time, - }; - } else if (SavedSourceConfigurationMessageColumnRuntimeType.is(logColumn)) { - return { - columnId: logColumn.messageColumn.id, - message: formatLogMessage(document.fields, document.highlights), - }; - } else { - return { - columnId: logColumn.fieldColumn.id, - field: logColumn.fieldColumn.field, - highlights: document.highlights[logColumn.fieldColumn.field] || [], - value: stringify(document.fields[logColumn.fieldColumn.field] || null), - }; - } - }), -}); - const logSummaryBucketHasEntries = (bucket: LogSummaryBucket) => bucket.entriesCount > 0 && bucket.topEntryKeys.length > 0; diff --git a/x-pack/plugins/infra/server/routes/log_entries/entries.ts b/x-pack/plugins/infra/server/routes/log_entries/entries.ts index 93802468dd267..f33dfa71fedcd 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/entries.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/entries.ts @@ -38,13 +38,19 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) fold(throwErrors(Boom.badRequest), identity) ); - const { startDate, endDate, sourceId, query, size } = payload; + const { + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + sourceId, + query, + size, + } = payload; let entries; if ('center' in payload) { entries = await logEntries.getLogEntriesAround__new(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), center: payload.center, size, @@ -58,20 +64,22 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) } entries = await logEntries.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), cursor, size, }); } + const hasEntries = entries.length > 0; + return response.ok({ body: logEntriesResponseRT.encode({ data: { entries, - topCursor: entries[0].cursor, - bottomCursor: entries[entries.length - 1].cursor, + topCursor: hasEntries ? entries[0].cursor : null, + bottomCursor: hasEntries ? entries[entries.length - 1].cursor : null, }, }), }); diff --git a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts index 8ee412d5acdd5..2e581d96cab9c 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts @@ -38,7 +38,7 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa fold(throwErrors(Boom.badRequest), identity) ); - const { startDate, endDate, sourceId, query, size, highlightTerms } = payload; + const { startTimestamp, endTimestamp, sourceId, query, size, highlightTerms } = payload; let entriesPerHighlightTerm; @@ -46,8 +46,8 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa entriesPerHighlightTerm = await Promise.all( highlightTerms.map(highlightTerm => logEntries.getLogEntriesAround__new(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), center: payload.center, size, @@ -66,8 +66,8 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa entriesPerHighlightTerm = await Promise.all( highlightTerms.map(highlightTerm => logEntries.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), cursor, size, diff --git a/x-pack/plugins/infra/server/routes/log_entries/summary.ts b/x-pack/plugins/infra/server/routes/log_entries/summary.ts index 3f5bc8e364a58..aa4421374ec12 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/summary.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/summary.ts @@ -36,13 +36,13 @@ export const initLogEntriesSummaryRoute = ({ framework, logEntries }: InfraBacke logEntriesSummaryRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const { sourceId, startDate, endDate, bucketSize, query } = payload; + const { sourceId, startTimestamp, endTimestamp, bucketSize, query } = payload; const buckets = await logEntries.getLogSummaryBucketsBetween( requestContext, sourceId, - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, parseFilterQuery(query) ); @@ -50,8 +50,8 @@ export const initLogEntriesSummaryRoute = ({ framework, logEntries }: InfraBacke return response.ok({ body: logEntriesSummaryResponseRT.encode({ data: { - start: startDate, - end: endDate, + start: startTimestamp, + end: endTimestamp, buckets, }, }), diff --git a/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts b/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts index 6c6f7a5a3dcd3..d92cddcdc415d 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts @@ -39,13 +39,20 @@ export const initLogEntriesSummaryHighlightsRoute = ({ logEntriesSummaryHighlightsRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const { sourceId, startDate, endDate, bucketSize, query, highlightTerms } = payload; + const { + sourceId, + startTimestamp, + endTimestamp, + bucketSize, + query, + highlightTerms, + } = payload; const bucketsPerHighlightTerm = await logEntries.getLogSummaryHighlightBucketsBetween( requestContext, sourceId, - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, highlightTerms, parseFilterQuery(query) @@ -54,8 +61,8 @@ export const initLogEntriesSummaryHighlightsRoute = ({ return response.ok({ body: logEntriesSummaryHighlightsResponseRT.encode({ data: bucketsPerHighlightTerm.map(buckets => ({ - start: startDate, - end: endDate, + start: startTimestamp, + end: endTimestamp, buckets, })), }), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 3855dd72dcdfd..03bfb089d8bd0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6409,7 +6409,6 @@ "xpack.infra.logs.analysisPage.unavailable.mlAppLink": "機械学習アプリ", "xpack.infra.logs.customizeLogs.customizeButtonLabel": "カスタマイズ", "xpack.infra.logs.customizeLogs.lineWrappingFormRowLabel": "改行", - "xpack.infra.logs.customizeLogs.minimapScaleFormRowLabel": "ミニマップスケール", "xpack.infra.logs.customizeLogs.textSizeFormRowLabel": "テキストサイズ", "xpack.infra.logs.customizeLogs.textSizeRadioGroup": "{textScale, select, small {小さい} 中くらい {Medium} 大きい {Large} その他の {{textScale}} }", "xpack.infra.logs.customizeLogs.wrapLongLinesSwitchLabel": "長い行を改行", @@ -6424,19 +6423,12 @@ "xpack.infra.logs.index.settingsTabTitle": "設定", "xpack.infra.logs.index.streamTabTitle": "ストリーム", "xpack.infra.logs.jumpToTailText": "最も新しいエントリーに移動", - "xpack.infra.logs.lastStreamingUpdateText": " 最終更新 {lastUpdateTime}", - "xpack.infra.logs.loadAgainButtonLabel": "再読み込み", - "xpack.infra.logs.loadingAdditionalEntriesText": "追加エントリーを読み込み中", - "xpack.infra.logs.noAdditionalEntriesFoundText": "追加エントリーが見つかりません", "xpack.infra.logs.scrollableLogTextStreamView.loadingEntriesLabel": "エントリーを読み込み中", "xpack.infra.logs.search.nextButtonLabel": "次へ", "xpack.infra.logs.search.previousButtonLabel": "前へ", "xpack.infra.logs.search.searchInLogsAriaLabel": "検索", "xpack.infra.logs.search.searchInLogsPlaceholder": "検索", "xpack.infra.logs.searchResultTooltip": "{bucketCount, plural, one {# 件のハイライトされたエントリー} other {# 件のハイライトされたエントリー}}", - "xpack.infra.logs.startStreamingButtonLabel": "ライブストリーム", - "xpack.infra.logs.stopStreamingButtonLabel": "ストリーム停止", - "xpack.infra.logs.streamingDescription": "新しいエントリーをストリーム中...", "xpack.infra.logs.streamingNewEntriesText": "新しいエントリーをストリーム中", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | ストリーム", "xpack.infra.logsPage.noLoggingIndicesDescription": "追加しましょう!", @@ -6444,12 +6436,6 @@ "xpack.infra.logsPage.noLoggingIndicesTitle": "ログインデックスがないようです。", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "ログエントリーを検索", "xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder": "ログエントリーを検索中… (例: host.name:host-1)", - "xpack.infra.mapLogs.oneDayLabel": "1 日", - "xpack.infra.mapLogs.oneHourLabel": "1 時間", - "xpack.infra.mapLogs.oneMinuteLabel": "1 分", - "xpack.infra.mapLogs.oneMonthLabel": "1 か月", - "xpack.infra.mapLogs.oneWeekLabel": "1 週間", - "xpack.infra.mapLogs.oneYearLabel": "1 年", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "パーセント", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "CPU 使用状況", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "読み取り", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 030b27c2342cc..682ac4c0bba10 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6409,7 +6409,6 @@ "xpack.infra.logs.analysisPage.unavailable.mlAppLink": "Machine Learning 应用", "xpack.infra.logs.customizeLogs.customizeButtonLabel": "定制", "xpack.infra.logs.customizeLogs.lineWrappingFormRowLabel": "换行", - "xpack.infra.logs.customizeLogs.minimapScaleFormRowLabel": "迷你地图比例", "xpack.infra.logs.customizeLogs.textSizeFormRowLabel": "文本大小", "xpack.infra.logs.customizeLogs.textSizeRadioGroup": "{textScale, select, small {小} medium {Medium} large {Large} other {{textScale}} }", "xpack.infra.logs.customizeLogs.wrapLongLinesSwitchLabel": "长行换行", @@ -6424,19 +6423,12 @@ "xpack.infra.logs.index.settingsTabTitle": "设置", "xpack.infra.logs.index.streamTabTitle": "流式传输", "xpack.infra.logs.jumpToTailText": "跳到最近的条目", - "xpack.infra.logs.lastStreamingUpdateText": " 最后更新时间:{lastUpdateTime}", - "xpack.infra.logs.loadAgainButtonLabel": "重新加载", - "xpack.infra.logs.loadingAdditionalEntriesText": "正在加载其他条目", - "xpack.infra.logs.noAdditionalEntriesFoundText": "找不到其他条目", "xpack.infra.logs.scrollableLogTextStreamView.loadingEntriesLabel": "正在加载条目", "xpack.infra.logs.search.nextButtonLabel": "下一个", "xpack.infra.logs.search.previousButtonLabel": "上一页", "xpack.infra.logs.search.searchInLogsAriaLabel": "搜索", "xpack.infra.logs.search.searchInLogsPlaceholder": "搜索", "xpack.infra.logs.searchResultTooltip": "{bucketCount, plural, one {# 个高亮条目} other {# 个高亮条目}}", - "xpack.infra.logs.startStreamingButtonLabel": "实时流式传输", - "xpack.infra.logs.stopStreamingButtonLabel": "停止流式传输", - "xpack.infra.logs.streamingDescription": "正在流式传输新条目……", "xpack.infra.logs.streamingNewEntriesText": "正在流式传输新条目", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | 流式传输", "xpack.infra.logsPage.noLoggingIndicesDescription": "让我们添加一些!", @@ -6444,12 +6436,6 @@ "xpack.infra.logsPage.noLoggingIndicesTitle": "似乎您没有任何日志索引。", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "搜索日志条目", "xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder": "搜索日志条目……(例如 host.name:host-1)", - "xpack.infra.mapLogs.oneDayLabel": "1 日", - "xpack.infra.mapLogs.oneHourLabel": "1 小时", - "xpack.infra.mapLogs.oneMinuteLabel": "1 分钟", - "xpack.infra.mapLogs.oneMonthLabel": "1 个月", - "xpack.infra.mapLogs.oneWeekLabel": "1 周", - "xpack.infra.mapLogs.oneYearLabel": "1 年", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "百分比", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "CPU 使用率", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "读取数", diff --git a/x-pack/test/api_integration/apis/infra/index.js b/x-pack/test/api_integration/apis/infra/index.js index fad387130e044..f5bdf280c46d2 100644 --- a/x-pack/test/api_integration/apis/infra/index.js +++ b/x-pack/test/api_integration/apis/infra/index.js @@ -10,8 +10,8 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./log_analysis')); loadTestFile(require.resolve('./log_entries')); loadTestFile(require.resolve('./log_entry_highlights')); - loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./logs_without_millis')); + loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./metrics')); loadTestFile(require.resolve('./sources')); loadTestFile(require.resolve('./waffle')); diff --git a/x-pack/test/api_integration/apis/infra/log_entries.ts b/x-pack/test/api_integration/apis/infra/log_entries.ts index 75e7750058a87..4f447d518a751 100644 --- a/x-pack/test/api_integration/apis/infra/log_entries.ts +++ b/x-pack/test/api_integration/apis/infra/log_entries.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { v4 as uuidv4 } from 'uuid'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -19,10 +17,11 @@ import { LOG_ENTRIES_PATH, logEntriesRequestRT, logEntriesResponseRT, + LogTimestampColumn, + LogFieldColumn, + LogMessageColumn, } from '../../../../plugins/infra/common/http_api'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; import { FtrProviderContext } from '../../ftr_provider_context'; const KEY_WITHIN_DATA_RANGE = { @@ -38,75 +37,12 @@ const LATEST_KEY_WITH_DATA = { tiebreaker: 5603910, }; -const logEntriesAroundQuery = gql` - query LogEntriesAroundQuery( - $timeKey: InfraTimeKeyInput! - $countBefore: Int = 0 - $countAfter: Int = 0 - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesAround( - key: $timeKey - countBefore: $countBefore - countAfter: $countAfter - filterQuery: $filterQuery - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const logEntriesBetweenQuery = gql` - query LogEntriesBetweenQuery( - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesBetween(startKey: $startKey, endKey: $endKey, filterQuery: $filterQuery) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - const COMMON_HEADERS = { 'kbn-xsrf': 'some-xsrf-token', }; export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const client = getService('infraOpsGraphQLClient'); const supertest = getService('supertest'); const sourceConfigurationService = getService('infraOpsSourceConfiguration'); @@ -126,8 +62,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, }) ) .expect(200); @@ -154,6 +90,42 @@ export default function({ getService }: FtrProviderContext) { expect(lastEntry.cursor.time <= KEY_WITHIN_DATA_RANGE.time).to.be(true); }); + it('Returns the default columns', async () => { + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + + const entries = logEntriesResponse.data.entries; + const entry = entries[0]; + expect(entry.columns).to.have.length(3); + + const timestampColumn = entry.columns[0] as LogTimestampColumn; + expect(timestampColumn).to.have.property('timestamp'); + + const eventDatasetColumn = entry.columns[1] as LogFieldColumn; + expect(eventDatasetColumn).to.have.property('field'); + expect(eventDatasetColumn.field).to.be('event.dataset'); + expect(eventDatasetColumn).to.have.property('value'); + + const messageColumn = entry.columns[2] as LogMessageColumn; + expect(messageColumn).to.have.property('message'); + expect(messageColumn.message.length).to.be.greaterThan(0); + }); + it('Paginates correctly with `after`', async () => { const { body: firstPageBody } = await supertest .post(LOG_ENTRIES_PATH) @@ -161,8 +133,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, size: 10, }) ); @@ -177,9 +149,9 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, - after: firstPage.data.bottomCursor, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, + after: firstPage.data.bottomCursor!, size: 10, }) ); @@ -194,8 +166,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, size: 20, }) ); @@ -220,8 +192,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, before: 'last', size: 10, }) @@ -237,9 +209,9 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, - before: lastPage.data.topCursor, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + before: lastPage.data.topCursor!, size: 10, }) ); @@ -254,8 +226,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, before: 'last', size: 20, }) @@ -281,8 +253,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, center: KEY_WITHIN_DATA_RANGE, }) ) @@ -300,101 +272,31 @@ export default function({ getService }: FtrProviderContext) { expect(firstEntry.cursor.time >= EARLIEST_KEY_WITH_DATA.time).to.be(true); expect(lastEntry.cursor.time <= LATEST_KEY_WITH_DATA.time).to.be(true); }); - }); - }); - - describe('logEntriesAround', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); - - it('should return newer and older log entries when present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 100, - countAfter: 100, - }, - }); - expect(logEntriesAround).to.have.property('entries'); - expect(logEntriesAround.entries).to.have.length(200); - expect(isSorted(ascendingTimeKey)(logEntriesAround.entries)).to.equal(true); + it('Handles empty responses', async () => { + const startTimestamp = Date.now() + 1000; + const endTimestamp = Date.now() + 5000; - expect(logEntriesAround.hasMoreBefore).to.equal(true); - expect(logEntriesAround.hasMoreAfter).to.equal(true); - }); - - it('should indicate if no older entries are present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: EARLIEST_KEY_WITH_DATA, - countBefore: 100, - countAfter: 100, - }, - }); - - expect(logEntriesAround.hasMoreBefore).to.equal(false); - expect(logEntriesAround.hasMoreAfter).to.equal(true); - }); - - it('should indicate if no newer entries are present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: LATEST_KEY_WITH_DATA, - countBefore: 100, - countAfter: 100, - }, - }); - - expect(logEntriesAround.hasMoreBefore).to.equal(true); - expect(logEntriesAround.hasMoreAfter).to.equal(false); - }); + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + }) + ) + .expect(200); - it('should return the default columns', async () => { - const { - data: { - source: { - logEntriesAround: { - entries: [entry], - }, - }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countAfter: 1, - }, - }); + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); - expect(entry.columns).to.have.length(3); - expect(entry.columns[0]).to.have.property('timestamp'); - expect(entry.columns[0].timestamp).to.be.a('number'); - expect(entry.columns[1]).to.have.property('field'); - expect(entry.columns[1].field).to.be('event.dataset'); - expect(entry.columns[1]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[1].value) - .to.not.throwException(); - expect(entry.columns[2]).to.have.property('message'); - expect(entry.columns[2].message).to.be.an('array'); - expect(entry.columns[2].message.length).to.be.greaterThan(0); + expect(logEntriesResponse.data.entries).to.have.length(0); + expect(logEntriesResponse.data.topCursor).to.be(null); + expect(logEntriesResponse.data.bottomCursor).to.be(null); }); }); @@ -431,120 +333,48 @@ export default function({ getService }: FtrProviderContext) { }); after(() => esArchiver.unload('empty_kibana')); - it('should return the configured columns', async () => { - const { - data: { - source: { - logEntriesAround: { - entries: [entry], - }, - }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countAfter: 1, - }, - }); + it('returns the configured columns', async () => { + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); - expect(entry.columns).to.have.length(4); - expect(entry.columns[0]).to.have.property('timestamp'); - expect(entry.columns[0].timestamp).to.be.a('number'); - expect(entry.columns[1]).to.have.property('field'); - expect(entry.columns[1].field).to.be('host.name'); - expect(entry.columns[1]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[1].value) - .to.not.throwException(); - expect(entry.columns[2]).to.have.property('field'); - expect(entry.columns[2].field).to.be('event.dataset'); - expect(entry.columns[2]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[2].value) - .to.not.throwException(); - expect(entry.columns[3]).to.have.property('message'); - expect(entry.columns[3].message).to.be.an('array'); - expect(entry.columns[3].message.length).to.be.greaterThan(0); - }); - }); - }); + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); - describe('logEntriesBetween', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); + const entries = logEntriesResponse.data.entries; + const entry = entries[0]; - it('should return log entries between the start and end keys', async () => { - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: EARLIEST_KEY_WITH_DATA, - endKey: KEY_WITHIN_DATA_RANGE, - }, - }); + expect(entry.columns).to.have.length(4); - expect(logEntriesBetween).to.have.property('entries'); - expect(logEntriesBetween.entries).to.not.be.empty(); - expect(isSorted(ascendingTimeKey)(logEntriesBetween.entries)).to.equal(true); - - expect( - ascendingTimeKey(logEntriesBetween.entries[0], { key: EARLIEST_KEY_WITH_DATA }) - ).to.be.above(-1); - expect( - ascendingTimeKey(logEntriesBetween.entries[logEntriesBetween.entries.length - 1], { - key: KEY_WITHIN_DATA_RANGE, - }) - ).to.be.below(1); - }); + const timestampColumn = entry.columns[0] as LogTimestampColumn; + expect(timestampColumn).to.have.property('timestamp'); - it('should return results consistent with logEntriesAround', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 100, - countAfter: 100, - }, - }); + const hostNameColumn = entry.columns[1] as LogFieldColumn; + expect(hostNameColumn).to.have.property('field'); + expect(hostNameColumn.field).to.be('host.name'); + expect(hostNameColumn).to.have.property('value'); - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: { - time: logEntriesAround.start.time, - tiebreaker: logEntriesAround.start.tiebreaker - 1, - }, - endKey: { - time: logEntriesAround.end.time, - tiebreaker: logEntriesAround.end.tiebreaker + 1, - }, - }, - }); + const eventDatasetColumn = entry.columns[2] as LogFieldColumn; + expect(eventDatasetColumn).to.have.property('field'); + expect(eventDatasetColumn.field).to.be('event.dataset'); + expect(eventDatasetColumn).to.have.property('value'); - expect(logEntriesBetween).to.eql(logEntriesAround); + const messageColumn = entry.columns[3] as LogMessageColumn; + expect(messageColumn).to.have.property('message'); + expect(messageColumn.message.length).to.be.greaterThan(0); }); }); }); }); } - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts b/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts index a34cd89eb3262..94f9d31ae8923 100644 --- a/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts +++ b/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; @@ -21,21 +19,11 @@ import { } from '../../../../plugins/infra/common/http_api'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; const KEY_BEFORE_START = { time: new Date('2000-01-01T00:00:00.000Z').valueOf(), tiebreaker: -1, }; -const KEY_AFTER_START = { - time: new Date('2000-01-01T00:00:04.000Z').valueOf(), - tiebreaker: -1, -}; -const KEY_BEFORE_END = { - time: new Date('2000-01-01T00:00:06.001Z').valueOf(), - tiebreaker: 0, -}; const KEY_AFTER_END = { time: new Date('2000-01-01T00:00:09.001Z').valueOf(), tiebreaker: 0, @@ -48,7 +36,6 @@ const COMMON_HEADERS = { export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - const client = getService('infraOpsGraphQLClient'); describe('log highlight apis', () => { before(() => esArchiver.load('infra/simple_logs')); @@ -66,8 +53,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, highlightTerms: ['message of document 0'], }) ) @@ -116,8 +103,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, highlightTerms: ['generate_test_data/simple_logs'], }) ) @@ -152,8 +139,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, query: JSON.stringify({ multi_match: { query: 'host-a', type: 'phrase', lenient: true }, }), @@ -185,236 +172,5 @@ export default function({ getService }: FtrProviderContext) { }); }); }); - - describe('logEntryHighlights', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); - - it('should return log highlights in the built-in message column', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - highlights: [ - { - query: 'message of document 0', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // ten bundles with one highlight each - expect(logEntryHighlightSet.entries).to.have.length(10); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'of', - 'document', - '0', - ]); - } - }); - - // https://github.com/elastic/kibana/issues/49959 - it.skip('should return log highlights in a field column', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - highlights: [ - { - query: 'generate_test_data/simple_logs', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // ten bundles with five highlights each - expect(logEntryHighlightSet.entries).to.have.length(50); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([ - 'generate_test_data/simple_logs', - ]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([]); - } - }); - - it('should apply the filter query in addition to the highlight query', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - filterQuery: JSON.stringify({ - multi_match: { query: 'host-a', type: 'phrase', lenient: true }, - }), - highlights: [ - { - query: 'message', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // half of the documenst - expect(logEntryHighlightSet.entries).to.have.length(25); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'message', - ]); - } - }); - - it('should return highlights outside of the interval when requested', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_AFTER_START, - endKey: KEY_BEFORE_END, - highlights: [ - { - query: 'message of document 0', - countBefore: 2, - countAfter: 2, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // three bundles with one highlight each plus two beyond each interval boundary - expect(logEntryHighlightSet.entries).to.have.length(3 + 4); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'of', - 'document', - '0', - ]); - } - }); - }); - }); }); } - -const logEntryHighlightsQuery = gql` - query LogEntryHighlightsQuery( - $sourceId: ID = "default" - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - $highlights: [InfraLogEntryHighlightInput!]! - ) { - source(id: $sourceId) { - id - logEntryHighlights( - startKey: $startKey - endKey: $endKey - filterQuery: $filterQuery - highlights: $highlights - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - entries { - ...InfraLogEntryHighlightFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryHighlightFields} -`; - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/api_integration/apis/infra/log_summary.ts b/x-pack/test/api_integration/apis/infra/log_summary.ts index 15e503f7b4a5a..1f1b65fca6e5f 100644 --- a/x-pack/test/api_integration/apis/infra/log_summary.ts +++ b/x-pack/test/api_integration/apis/infra/log_summary.ts @@ -38,9 +38,10 @@ export default function({ getService }: FtrProviderContext) { after(() => esArchiver.unload('infra/metrics_and_logs')); it('should return empty and non-empty consecutive buckets', async () => { - const startDate = EARLIEST_TIME_WITH_DATA; - const endDate = LATEST_TIME_WITH_DATA + (LATEST_TIME_WITH_DATA - EARLIEST_TIME_WITH_DATA); - const bucketSize = Math.ceil((endDate - startDate) / 10); + const startTimestamp = EARLIEST_TIME_WITH_DATA; + const endTimestamp = + LATEST_TIME_WITH_DATA + (LATEST_TIME_WITH_DATA - EARLIEST_TIME_WITH_DATA); + const bucketSize = Math.ceil((endTimestamp - startTimestamp) / 10); const { body } = await supertest .post(LOG_ENTRIES_SUMMARY_PATH) @@ -48,8 +49,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesSummaryRequestRT.encode({ sourceId: 'default', - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, query: null, }) diff --git a/x-pack/test/api_integration/apis/infra/logs_without_millis.ts b/x-pack/test/api_integration/apis/infra/logs_without_millis.ts index 9295380cfbec1..642f4fb42d324 100644 --- a/x-pack/test/api_integration/apis/infra/logs_without_millis.ts +++ b/x-pack/test/api_integration/apis/infra/logs_without_millis.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; @@ -15,21 +13,18 @@ import { fold } from 'fp-ts/lib/Either'; import { createPlainError, throwErrors } from '../../../../plugins/infra/common/runtime_types'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; import { LOG_ENTRIES_SUMMARY_PATH, logEntriesSummaryRequestRT, logEntriesSummaryResponseRT, + LOG_ENTRIES_PATH, + logEntriesRequestRT, + logEntriesResponseRT, } from '../../../../plugins/infra/common/http_api/log_entries'; const COMMON_HEADERS = { 'kbn-xsrf': 'some-xsrf-token', }; -const KEY_WITHIN_DATA_RANGE = { - time: new Date('2019-01-06T00:00:00.000Z').valueOf(), - tiebreaker: 0, -}; const EARLIEST_KEY_WITH_DATA = { time: new Date('2019-01-05T23:59:23.000Z').valueOf(), tiebreaker: -1, @@ -38,153 +33,97 @@ const LATEST_KEY_WITH_DATA = { time: new Date('2019-01-06T23:59:23.000Z').valueOf(), tiebreaker: 2, }; +const KEY_WITHIN_DATA_RANGE = { + time: new Date('2019-01-06T00:00:00.000Z').valueOf(), + tiebreaker: 0, +}; export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const client = getService('infraOpsGraphQLClient'); const supertest = getService('supertest'); describe('logs without epoch_millis format', () => { before(() => esArchiver.load('infra/logs_without_epoch_millis')); after(() => esArchiver.unload('infra/logs_without_epoch_millis')); - it('logEntriesAround should return log entries', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 1, - countAfter: 1, - }, + describe('/log_entries/summary', () => { + it('returns non-empty buckets', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + const bucketSize = Math.ceil((endTimestamp - startTimestamp) / 10); + + const { body } = await supertest + .post(LOG_ENTRIES_SUMMARY_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesSummaryRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + bucketSize, + query: null, + }) + ) + .expect(200); + + const logSummaryResponse = pipe( + logEntriesSummaryResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + + expect( + logSummaryResponse.data.buckets.filter((bucket: any) => bucket.entriesCount > 0) + ).to.have.length(2); }); - - expect(logEntriesAround).to.have.property('entries'); - expect(logEntriesAround.entries).to.have.length(2); - expect(isSorted(ascendingTimeKey)(logEntriesAround.entries)).to.equal(true); - - expect(logEntriesAround.hasMoreBefore).to.equal(false); - expect(logEntriesAround.hasMoreAfter).to.equal(false); }); - it('logEntriesBetween should return log entries', async () => { - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: EARLIEST_KEY_WITH_DATA, - endKey: LATEST_KEY_WITH_DATA, - }, + describe('/log_entries/entries', () => { + it('returns log entries', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + expect(logEntriesResponse.data.entries).to.have.length(2); }); - expect(logEntriesBetween).to.have.property('entries'); - expect(logEntriesBetween.entries).to.have.length(2); - expect(isSorted(ascendingTimeKey)(logEntriesBetween.entries)).to.equal(true); - }); - - it('logSummaryBetween should return non-empty buckets', async () => { - const startDate = EARLIEST_KEY_WITH_DATA.time; - const endDate = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive - const bucketSize = Math.ceil((endDate - startDate) / 10); - - const { body } = await supertest - .post(LOG_ENTRIES_SUMMARY_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesSummaryRequestRT.encode({ - sourceId: 'default', - startDate, - endDate, - bucketSize, - query: null, - }) - ) - .expect(200); - - const logSummaryResponse = pipe( - logEntriesSummaryResponseRT.decode(body), - fold(throwErrors(createPlainError), identity) - ); - - expect( - logSummaryResponse.data.buckets.filter((bucket: any) => bucket.entriesCount > 0) - ).to.have.length(2); + it('returns log entries when centering around a point', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + expect(logEntriesResponse.data.entries).to.have.length(2); + }); }); }); } - -const logEntriesAroundQuery = gql` - query LogEntriesAroundQuery( - $timeKey: InfraTimeKeyInput! - $countBefore: Int = 0 - $countAfter: Int = 0 - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesAround( - key: $timeKey - countBefore: $countBefore - countAfter: $countAfter - filterQuery: $filterQuery - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const logEntriesBetweenQuery = gql` - query LogEntriesBetweenQuery( - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesBetween(startKey: $startKey, endKey: $endKey, filterQuery: $filterQuery) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/functional/apps/infra/constants.ts b/x-pack/test/functional/apps/infra/constants.ts index 947131a22d39b..cd91867faf9df 100644 --- a/x-pack/test/functional/apps/infra/constants.ts +++ b/x-pack/test/functional/apps/infra/constants.ts @@ -22,5 +22,9 @@ export const DATES = { withData: '10/17/2018 7:58:03 PM', withoutData: '10/09/2018 10:00:00 PM', }, + stream: { + startWithData: '2018-10-17T19:42:22.000Z', + endWithData: '2018-10-17T19:57:21.000Z', + }, }, }; diff --git a/x-pack/test/functional/apps/infra/link_to.ts b/x-pack/test/functional/apps/infra/link_to.ts index da41bf285c3e4..7e79f42ac94cb 100644 --- a/x-pack/test/functional/apps/infra/link_to.ts +++ b/x-pack/test/functional/apps/infra/link_to.ts @@ -7,22 +7,29 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +const ONE_HOUR = 60 * 60 * 1000; + export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common']); const retry = getService('retry'); const browser = getService('browser'); + const timestamp = Date.now(); + const startDate = new Date(timestamp - ONE_HOUR).toISOString(); + const endDate = new Date(timestamp + ONE_HOUR).toISOString(); + + const traceId = '433b4651687e18be2c6c8e3b11f53d09'; + describe('Infra link-to', function() { this.tags('smoke'); it('redirects to the logs app and parses URL search params correctly', async () => { const location = { hash: '', pathname: '/link-to/logs', - search: 'time=1565707203194&filter=trace.id:433b4651687e18be2c6c8e3b11f53d09', + search: `time=${timestamp}&filter=trace.id:${traceId}`, state: undefined, }; - const expectedSearchString = - "logFilter=(expression:'trace.id:433b4651687e18be2c6c8e3b11f53d09',kind:kuery)&logPosition=(position:(tiebreaker:0,time:1565707203194),streamLive:!f)&sourceId=default"; + const expectedSearchString = `logFilter=(expression:'trace.id:${traceId}',kind:kuery)&logPosition=(end:'${endDate}',position:(tiebreaker:0,time:${timestamp}),start:'${startDate}',streamLive:!f)&sourceId=default`; const expectedRedirectPath = '/logs/stream?'; await pageObjects.common.navigateToUrlWithBrowserHistory( diff --git a/x-pack/test/functional/apps/infra/logs_source_configuration.ts b/x-pack/test/functional/apps/infra/logs_source_configuration.ts index ecad5a40ec42e..f40c908f23c80 100644 --- a/x-pack/test/functional/apps/infra/logs_source_configuration.ts +++ b/x-pack/test/functional/apps/infra/logs_source_configuration.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { DATES } from './constants'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -74,7 +75,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the default log columns with their headers', async () => { - await logsUi.logStreamPage.navigateTo(); + await logsUi.logStreamPage.navigateTo({ + logPosition: { + start: DATES.metricsAndLogs.stream.startWithData, + end: DATES.metricsAndLogs.stream.endWithData, + }, + }); await retry.try(async () => { const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); @@ -108,7 +114,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the changed log columns with their headers', async () => { - await logsUi.logStreamPage.navigateTo(); + await logsUi.logStreamPage.navigateTo({ + logPosition: { + start: DATES.metricsAndLogs.stream.startWithData, + end: DATES.metricsAndLogs.stream.endWithData, + }, + }); await retry.try(async () => { const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); diff --git a/x-pack/test/functional/page_objects/infra_logs_page.ts b/x-pack/test/functional/page_objects/infra_logs_page.ts index 8f554729328bb..10d86140fd121 100644 --- a/x-pack/test/functional/page_objects/infra_logs_page.ts +++ b/x-pack/test/functional/page_objects/infra_logs_page.ts @@ -6,8 +6,21 @@ // import testSubjSelector from '@kbn/test-subj-selector'; // import moment from 'moment'; - +import querystring from 'querystring'; +import { encode, RisonValue } from 'rison-node'; import { FtrProviderContext } from '../ftr_provider_context'; +import { LogPositionUrlState } from '../../../../x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state'; +import { FlyoutOptionsUrlState } from '../../../../x-pack/plugins/infra/public/containers/logs/log_flyout'; + +export interface TabsParams { + stream: { + logPosition?: Partial; + flyoutOptions?: Partial; + }; + settings: never; + 'log-categories': any; + 'log-rate': any; +} export function InfraLogsPageProvider({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); @@ -18,8 +31,26 @@ export function InfraLogsPageProvider({ getPageObjects, getService }: FtrProvide await pageObjects.common.navigateToApp('infraLogs'); }, - async navigateToTab(logsUiTab: LogsUiTab) { - await pageObjects.common.navigateToUrlWithBrowserHistory('infraLogs', `/${logsUiTab}`); + async navigateToTab(logsUiTab: T, params?: TabsParams[T]) { + let qs = ''; + if (params) { + const parsedParams: Record = {}; + + for (const key in params) { + if (params.hasOwnProperty(key)) { + const value = (params[key] as unknown) as RisonValue; + parsedParams[key] = encode(value); + } + } + qs = '?' + querystring.stringify(parsedParams); + } + + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'infraLogs', + `/${logsUiTab}`, + qs, + { ensureCurrentUrl: false } // Test runner struggles with `rison-node` escaped values + ); }, async getLogStream() { diff --git a/x-pack/test/functional/services/logs_ui/log_stream.ts b/x-pack/test/functional/services/logs_ui/log_stream.ts index ce37d2d5a60da..75486534cf5cc 100644 --- a/x-pack/test/functional/services/logs_ui/log_stream.ts +++ b/x-pack/test/functional/services/logs_ui/log_stream.ts @@ -6,6 +6,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; +import { TabsParams } from '../../page_objects/infra_logs_page'; export function LogStreamPageProvider({ getPageObjects, getService }: FtrProviderContext) { const pageObjects = getPageObjects(['infraLogs']); @@ -13,8 +14,8 @@ export function LogStreamPageProvider({ getPageObjects, getService }: FtrProvide const testSubjects = getService('testSubjects'); return { - async navigateTo() { - pageObjects.infraLogs.navigateToTab('stream'); + async navigateTo(params?: TabsParams['stream']) { + pageObjects.infraLogs.navigateToTab('stream', params); }, async getColumnHeaderLabels(): Promise { From d5ed93ee635b93573eb8a367e9e22ba597d367a5 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 19 Mar 2020 10:27:41 -0600 Subject: [PATCH 09/13] [SIEM] [Cases] Case closed and add user email (#60463) --- .../siem/public/containers/case/types.ts | 7 +- .../public/containers/case/use_get_case.tsx | 2 + .../containers/case/use_update_case.tsx | 4 +- .../components/all_cases/__mock__/index.tsx | 14 +- .../case/components/all_cases/columns.tsx | 50 +++-- .../pages/case/components/all_cases/index.tsx | 18 +- .../case/components/all_cases/translations.ts | 6 - .../case/components/case_status/index.tsx | 105 +++++++++++ .../components/case_view/__mock__/index.tsx | 50 ++--- .../components/case_view/actions.test.tsx | 65 +++++++ .../case/components/case_view/actions.tsx | 75 ++++++++ .../case/components/case_view/index.test.tsx | 95 ++++------ .../pages/case/components/case_view/index.tsx | 171 +++++------------- .../case/components/case_view/translations.ts | 16 ++ .../case/components/user_list/index.test.tsx | 40 ++++ .../pages/case/components/user_list/index.tsx | 28 ++- .../siem/public/pages/case/translations.ts | 10 + x-pack/plugins/case/common/api/cases/case.ts | 2 + x-pack/plugins/case/common/api/user.ts | 1 + .../routes/api/__fixtures__/authc_mock.ts | 6 +- .../api/__fixtures__/mock_saved_objects.ts | 50 +++++ .../api/cases/comments/patch_comment.ts | 4 +- .../api/cases/configure/patch_configure.ts | 4 +- .../api/cases/configure/post_configure.ts | 4 +- .../routes/api/cases/find_cases.test.ts | 2 +- .../routes/api/cases/patch_cases.test.ts | 50 ++++- .../server/routes/api/cases/patch_cases.ts | 30 ++- .../plugins/case/server/routes/api/types.ts | 2 +- .../plugins/case/server/routes/api/utils.ts | 20 +- .../case/server/saved_object_types/cases.ts | 22 +++ .../server/saved_object_types/comments.ts | 6 + 31 files changed, 692 insertions(+), 267 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 65d94865bf00c..5b6ff8438be8c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -18,6 +18,8 @@ export interface Comment { export interface Case { id: string; + closedAt: string | null; + closedBy: ElasticUser | null; comments: Comment[]; commentIds: string[]; createdAt: string; @@ -59,12 +61,13 @@ export interface AllCases extends CasesStatus { export enum SortFieldCase { createdAt = 'createdAt', - updatedAt = 'updatedAt', + closedAt = 'closedAt', } export interface ElasticUser { - readonly username: string; + readonly email?: string | null; readonly fullName?: string | null; + readonly username: string; } export interface FetchCasesProps { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index a179b6f546b9b..b70195e2c126f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -49,6 +49,8 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { }; const initialData: Case = { id: '', + closedAt: null, + closedBy: null, createdAt: '', comments: [], commentIds: [], diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index afcbe20fa791a..987620469901b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -5,7 +5,7 @@ */ import { useReducer, useCallback } from 'react'; - +import { cloneDeep } from 'lodash/fp'; import { CaseRequest } from '../../../../../../plugins/case/common/api'; import { errorToToaster, useStateToaster } from '../../components/toasters'; @@ -47,7 +47,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state, isLoading: false, isError: false, - caseData: action.payload, + caseData: cloneDeep(action.payload), updateKey: null, }; case 'FETCH_FAILURE': diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 0fe8daafcb30a..5d00b770b3ca9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -13,6 +13,8 @@ export const useGetCasesMockState: UseGetCasesState = { countOpenCases: 0, cases: [ { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, @@ -27,6 +29,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, @@ -41,6 +45,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, @@ -55,6 +61,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: '2020-02-13T19:44:13.328Z', + closedBy: { username: 'elastic' }, id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, @@ -64,11 +72,13 @@ export const useGetCasesMockState: UseGetCasesState = { status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: null, - updatedBy: null, + updatedAt: '2020-02-13T19:44:13.328Z', + updatedBy: { username: 'elastic' }, version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 5859e6bbce263..b9e1113c486ad 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -36,7 +36,8 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); export const getCasesColumns = ( - actions: Array> + actions: Array>, + filterStatus: string ): CasesColumns[] => [ { name: i18n.NAME, @@ -113,22 +114,39 @@ export const getCasesColumns = ( render: (comments: Case['commentIds']) => renderStringField(`${comments.length}`, `case-table-column-commentCount`), }, - { - field: 'createdAt', - name: i18n.OPENED_ON, - sortable: true, - render: (createdAt: Case['createdAt']) => { - if (createdAt != null) { - return ( - - ); + filterStatus === 'open' + ? { + field: 'createdAt', + name: i18n.OPENED_ON, + sortable: true, + render: (createdAt: Case['createdAt']) => { + if (createdAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, } - return getEmptyTagValue(); - }, - }, + : { + field: 'closedAt', + name: i18n.CLOSED_ON, + sortable: true, + render: (closedAt: Case['closedAt']) => { + if (closedAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, + }, { name: 'Actions', actions, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 9f836bd043c9d..9a84dd07b0af4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -71,8 +71,8 @@ const ProgressLoader = styled(EuiProgress)` const getSortField = (field: string): SortFieldCase => { if (field === SortFieldCase.createdAt) { return SortFieldCase.createdAt; - } else if (field === SortFieldCase.updatedAt) { - return SortFieldCase.updatedAt; + } else if (field === SortFieldCase.closedAt) { + return SortFieldCase.closedAt; } return SortFieldCase.createdAt; }; @@ -206,17 +206,25 @@ export const AllCases = React.memo(() => { } setQueryParams(newQueryParams); }, - [setQueryParams, queryParams] + [queryParams] ); const onFilterChangedCallback = useCallback( (newFilterOptions: Partial) => { + if (newFilterOptions.status && newFilterOptions.status === 'closed') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.closedAt }); + } else if (newFilterOptions.status && newFilterOptions.status === 'open') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.createdAt }); + } setFilters({ ...filterOptions, ...newFilterOptions }); }, - [filterOptions, setFilters] + [filterOptions, queryParams] ); - const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [actions]); + const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions, filterOptions.status), [ + actions, + filterOptions.status, + ]); const memoizedPagination = useMemo( () => ({ pageIndex: queryParams.page - 1, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts index 27532e57166e1..8f79b78ef7568 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -60,9 +60,3 @@ export const CLOSED = i18n.translate('xpack.siem.case.caseTable.closed', { export const DELETE = i18n.translate('xpack.siem.case.caseTable.delete', { defaultMessage: 'Delete', }); -export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { - defaultMessage: 'Reopen case', -}); -export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { - defaultMessage: 'Close case', -}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx new file mode 100644 index 0000000000000..9dbd71ea3e34c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import styled, { css } from 'styled-components'; +import { + EuiBadge, + EuiButtonToggle, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import * as i18n from '../case_view/translations'; +import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; +import { CaseViewActions } from '../case_view/actions'; + +const MyDescriptionList = styled(EuiDescriptionList)` + ${({ theme }) => css` + & { + padding-right: ${theme.eui.euiSizeL}; + border-right: ${theme.eui.euiBorderThin}; + } + `} +`; + +interface CaseStatusProps { + 'data-test-subj': string; + badgeColor: string; + buttonLabel: string; + caseId: string; + caseTitle: string; + icon: string; + isLoading: boolean; + isSelected: boolean; + status: string; + title: string; + toggleStatusCase: (status: string) => void; + value: string | null; +} +const CaseStatusComp: React.FC = ({ + 'data-test-subj': dataTestSubj, + badgeColor, + buttonLabel, + caseId, + caseTitle, + icon, + isLoading, + isSelected, + status, + title, + toggleStatusCase, + value, +}) => { + const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ + toggleStatusCase, + ]); + return ( + + + + + + {i18n.STATUS} + + + {status} + + + + + {title} + + + + + + + + + + + + + + + + + + + ); +}; + +export const CaseStatus = React.memo(CaseStatusComp); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 53cc1f80b5c10..e11441eac3a9d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -10,6 +10,8 @@ import { Case } from '../../../../../containers/case/types'; export const caseProps: CaseProps = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', initialData: { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], comments: [ @@ -20,6 +22,7 @@ export const caseProps: CaseProps = { createdBy: { fullName: 'Steph Milovic', username: 'smilovic', + email: 'notmyrealemailfool@elastic.co', }, updatedAt: '2020-02-20T23:06:33.798Z', updatedBy: { @@ -29,7 +32,7 @@ export const caseProps: CaseProps = { }, ], createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { fullName: null, username: 'elastic' }, + createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', status: 'open', tags: ['defacement'], @@ -41,35 +44,22 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; - -export const data: Case = { - id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], - comments: [ - { - comment: 'Solve this fast!', - id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', - createdAt: '2020-02-20T23:06:33.798Z', - createdBy: { - fullName: 'Steph Milovic', - username: 'smilovic', - }, - updatedAt: '2020-02-20T23:06:33.798Z', - updatedBy: { - username: 'elastic', - }, - version: 'WzQ3LDFd', +export const caseClosedProps: CaseProps = { + ...caseProps, + initialData: { + ...caseProps.initialData, + closedAt: '2020-02-20T23:06:33.798Z', + closedBy: { + username: 'elastic', }, - ], - createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { username: 'elastic', fullName: null }, - description: 'Security banana Issue', - status: 'open', - tags: ['defacement'], - title: 'Another horrible breach!!', - updatedAt: '2020-02-19T15:02:57.995Z', - updatedBy: { - username: 'elastic', + status: 'closed', }, - version: 'WzQ3LDFd', +}; + +export const data: Case = { + ...caseProps.initialData, +}; + +export const dataClosed: Case = { + ...caseClosedProps.initialData, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx new file mode 100644 index 0000000000000..4e1e5ba753c36 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { CaseViewActions } from './actions'; +import { TestProviders } from '../../../../mock'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +jest.mock('../../../../containers/case/use_delete_cases'); +const useDeleteCasesMock = useDeleteCases as jest.Mock; + +describe('CaseView actions', () => { + const caseTitle = 'Cool title'; + const caseId = 'cool-id'; + const handleOnDeleteConfirm = jest.fn(); + const handleToggleModal = jest.fn(); + const dispatchResetIsDeleted = jest.fn(); + const defaultDeleteState = { + dispatchResetIsDeleted, + handleToggleModal, + handleOnDeleteConfirm, + isLoading: false, + isError: false, + isDeleted: false, + isDisplayConfirmDeleteModal: false, + }; + beforeEach(() => { + jest.resetAllMocks(); + useDeleteCasesMock.mockImplementation(() => defaultDeleteState); + }); + it('clicking trash toggles modal', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); + + wrapper + .find('button[data-test-subj="property-actions-ellipses"]') + .first() + .simulate('click'); + wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click'); + expect(handleToggleModal).toHaveBeenCalled(); + }); + it('toggle delete modal and confirm', () => { + useDeleteCasesMock.mockImplementation(() => ({ + ...defaultDeleteState, + isDisplayConfirmDeleteModal: true, + })); + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); + wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); + expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseId]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx new file mode 100644 index 0000000000000..88a717ac5fa6a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; + +import { Redirect } from 'react-router-dom'; +import * as i18n from './translations'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { SiemPageName } from '../../../home/types'; +import { PropertyActions } from '../property_actions'; + +interface CaseViewActions { + caseId: string; + caseTitle: string; +} + +const CaseViewActionsComponent: React.FC = ({ caseId, caseTitle }) => { + // Delete case + const { + handleToggleModal, + handleOnDeleteConfirm, + isDeleted, + isDisplayConfirmDeleteModal, + } = useDeleteCases(); + + const confirmDeleteModal = useMemo( + () => ( + + ), + [isDisplayConfirmDeleteModal] + ); + // TO DO refactor each of these const's into their own components + const propertyActions = useMemo( + () => [ + { + iconType: 'trash', + label: i18n.DELETE_CASE, + onClick: handleToggleModal, + }, + { + iconType: 'popout', + label: 'View ServiceNow incident', + onClick: () => null, + }, + { + iconType: 'importAction', + label: 'Update ServiceNow incident', + onClick: () => null, + }, + ], + [handleToggleModal] + ); + + if (isDeleted) { + return ; + } + return ( + <> + + {confirmDeleteModal} + + ); +}; + +export const CaseViewActions = React.memo(CaseViewActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 15d6cf7cf7317..ec18bdb2bf9ab 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -7,15 +7,13 @@ import React from 'react'; import { mount } from 'enzyme'; import { CaseComponent } from './'; -import * as updateHook from '../../../../containers/case/use_update_case'; -import * as deleteHook from '../../../../containers/case/use_delete_cases'; -import { caseProps, data } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; import { TestProviders } from '../../../../mock'; +import { useUpdateCase } from '../../../../containers/case/use_update_case'; +jest.mock('../../../../containers/case/use_update_case'); +const useUpdateCaseMock = useUpdateCase as jest.Mock; describe('CaseView ', () => { - const handleOnDeleteConfirm = jest.fn(); - const handleToggleModal = jest.fn(); - const dispatchResetIsDeleted = jest.fn(); const updateCaseProperty = jest.fn(); /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() @@ -28,15 +26,17 @@ describe('CaseView ', () => { }); /* eslint-enable no-console */ + const defaultUpdateCaseState = { + caseData: data, + isLoading: false, + isError: false, + updateKey: null, + updateCaseProperty, + }; + beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(updateHook, 'useUpdateCase').mockReturnValue({ - caseData: data, - isLoading: false, - isError: false, - updateKey: null, - updateCaseProperty, - }); + useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); }); it('should render CaseComponent', () => { @@ -69,6 +69,7 @@ describe('CaseView ', () => { .first() .text() ).toEqual(data.createdBy.username); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); expect( wrapper .find(`[data-test-subj="case-view-createdAt"]`) @@ -82,6 +83,30 @@ describe('CaseView ', () => { .prop('raw') ).toEqual(data.description); }); + it('should show closed indicators in header when case is closed', () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + caseData: dataClosed, + })); + const wrapper = mount( + + + + ); + expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); + expect( + wrapper + .find(`[data-test-subj="case-view-closedAt"]`) + .first() + .prop('value') + ).toEqual(dataClosed.closedAt); + expect( + wrapper + .find(`[data-test-subj="case-view-status"]`) + .first() + .text() + ).toEqual(dataClosed.status); + }); it('should dispatch update state when button is toggled', () => { const wrapper = mount( @@ -92,7 +117,7 @@ describe('CaseView ', () => { wrapper .find('input[data-test-subj="toggle-case-status"]') - .simulate('change', { target: { value: false } }); + .simulate('change', { target: { checked: true } }); expect(updateCaseProperty).toBeCalledWith({ updateKey: 'status', @@ -133,46 +158,4 @@ describe('CaseView ', () => { .prop('source') ).toEqual(data.comments[0].comment); }); - - it('toggle delete modal and cancel', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); - - wrapper - .find( - '[data-test-subj="case-view-actions"] button[data-test-subj="property-actions-ellipses"]' - ) - .first() - .simulate('click'); - wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click'); - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); - wrapper.find('button[data-test-subj="confirmModalCancelButton"]').simulate('click'); - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); - }); - - it('toggle delete modal and confirm', () => { - jest.spyOn(deleteHook, 'useDeleteCases').mockReturnValue({ - dispatchResetIsDeleted, - handleToggleModal, - handleOnDeleteConfirm, - isLoading: false, - isError: false, - isDeleted: false, - isDisplayConfirmDeleteModal: true, - }); - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); - wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); - expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseProps.caseId]); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 82216e88a091e..dce7bde2225c9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -5,26 +5,14 @@ */ import React, { useCallback, useMemo } from 'react'; -import { - EuiBadge, - EuiButtonToggle, - EuiDescriptionList, - EuiDescriptionListDescription, - EuiDescriptionListTitle, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import styled, { css } from 'styled-components'; -import { Redirect } from 'react-router-dom'; +import styled from 'styled-components'; import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; -import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; import { getCaseUrl } from '../../../../components/link_to'; import { HeaderPage } from '../../../../components/header_page'; import { EditableTitle } from '../../../../components/header_page/editable_title'; -import { PropertyActions } from '../property_actions'; import { TagList } from '../tag_list'; import { useGetCase } from '../../../../containers/case/use_get_case'; import { UserActionTree } from '../user_action_tree'; @@ -33,23 +21,13 @@ import { useUpdateCase } from '../../../../containers/case/use_update_case'; import { WrapperPage } from '../../../../components/wrapper_page'; import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; -import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; -import { SiemPageName } from '../../../home/types'; -import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { useBasePath } from '../../../../lib/kibana'; +import { CaseStatus } from '../case_status'; interface Props { caseId: string; } -const MyDescriptionList = styled(EuiDescriptionList)` - ${({ theme }) => css` - & { - padding-right: ${theme.eui.euiSizeL}; - border-right: ${theme.eui.euiBorderThin}; - } - `} -`; - const MyWrapper = styled(WrapperPage)` padding-bottom: 0; `; @@ -64,6 +42,8 @@ export interface CaseProps { } export const CaseComponent = React.memo(({ caseId, initialData }) => { + const basePath = window.location.origin + useBasePath(); + const caseLink = `${basePath}/app/siem#/case/${caseId}`; const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); // Update Fields @@ -107,58 +87,44 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => return null; } }, - [updateCaseProperty, caseData.status] - ); - const toggleStatusCase = useCallback( - e => onUpdateField('status', e.target.checked ? 'open' : 'closed'), - [onUpdateField] + [caseData.status] ); - const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); - - // Delete case - const { - handleToggleModal, - handleOnDeleteConfirm, - isDeleted, - isDisplayConfirmDeleteModal, - } = useDeleteCases(); - - const confirmDeleteModal = useMemo( - () => ( - - ), - [isDisplayConfirmDeleteModal] + const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); + const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); + + const caseStatusData = useMemo( + () => + caseData.status === 'open' + ? { + 'data-test-subj': 'case-view-createdAt', + value: caseData.createdAt, + title: i18n.CASE_OPENED, + buttonLabel: i18n.CLOSE_CASE, + status: caseData.status, + icon: 'checkInCircleFilled', + badgeColor: 'secondary', + isSelected: false, + } + : { + 'data-test-subj': 'case-view-closedAt', + value: caseData.closedAt, + title: i18n.CASE_CLOSED, + buttonLabel: i18n.REOPEN_CASE, + status: caseData.status, + icon: 'magnet', + badgeColor: 'danger', + isSelected: true, + }, + [caseData.closedAt, caseData.createdAt, caseData.status] + ); + const emailContent = useMemo( + () => ({ + subject: i18n.EMAIL_SUBJECT(caseData.title), + body: i18n.EMAIL_BODY(caseLink), + }), + [caseData.title] ); - // TO DO refactor each of these const's into their own components - const propertyActions = [ - { - iconType: 'trash', - label: 'Delete case', - onClick: handleToggleModal, - }, - { - iconType: 'popout', - label: 'View ServiceNow incident', - onClick: () => null, - }, - { - iconType: 'importAction', - label: 'Update ServiceNow incident', - onClick: () => null, - }, - ]; - - if (isDeleted) { - return ; - } - return ( <> @@ -177,51 +143,13 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => } title={caseData.title} > - - - - - - {i18n.STATUS} - - - {caseData.status} - - - - - {i18n.CASE_OPENED} - - - - - - - - - - - - - - - - - - + @@ -237,6 +165,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => @@ -250,7 +179,6 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => - {confirmDeleteModal} ); }); @@ -273,4 +201,5 @@ export const CaseView = React.memo(({ caseId }: Props) => { return ; }); +CaseComponent.displayName = 'CaseComponent'; CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index 82b5e771e2151..e5fa3bff51f85 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -55,3 +55,19 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { defaultMessage: 'Case opened', }); + +export const CASE_CLOSED = i18n.translate('xpack.siem.case.caseView.caseClosed', { + defaultMessage: 'Case closed', +}); + +export const EMAIL_SUBJECT = (caseTitle: string) => + i18n.translate('xpack.siem.case.caseView.emailSubject', { + values: { caseTitle }, + defaultMessage: 'SIEM Case - {caseTitle}', + }); + +export const EMAIL_BODY = (caseUrl: string) => + i18n.translate('xpack.siem.case.caseView.emailBody', { + values: { caseUrl }, + defaultMessage: 'Case reference: {caseUrl}', + }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx new file mode 100644 index 0000000000000..51acb3b810d92 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { UserList } from './'; +import * as i18n from '../case_view/translations'; + +describe('UserList ', () => { + const title = 'Case Title'; + const caseLink = 'http://reddit.com'; + const user = { username: 'username', fullName: 'Full Name', email: 'testemail@elastic.co' }; + const open = jest.fn(); + beforeAll(() => { + window.open = open; + }); + beforeEach(() => { + jest.resetAllMocks(); + }); + it('triggers mailto when email icon clicked', () => { + const wrapper = shallow( + + ); + wrapper.find('[data-test-subj="user-list-email-button"]').simulate('click'); + expect(open).toBeCalledWith( + `mailto:${user.email}?subject=${i18n.EMAIL_SUBJECT(title)}&body=${i18n.EMAIL_BODY(caseLink)}`, + '_blank' + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx index abb49122dc142..74a1b98c29eef 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiButtonIcon, EuiText, @@ -17,6 +17,10 @@ import styled, { css } from 'styled-components'; import { ElasticUser } from '../../../../containers/case/types'; interface UserListProps { + email: { + subject: string; + body: string; + }; headline: string; users: ElasticUser[]; } @@ -31,8 +35,11 @@ const MyFlexGroup = styled(EuiFlexGroup)` `} `; -const renderUsers = (users: ElasticUser[]) => { - return users.map(({ fullName, username }, key) => ( +const renderUsers = ( + users: ElasticUser[], + handleSendEmail: (emailAddress: string | undefined | null) => void +) => { + return users.map(({ fullName, username, email }, key) => ( @@ -50,7 +57,8 @@ const renderUsers = (users: ElasticUser[]) => { {}} // TO DO + data-test-subj="user-list-email-button" + onClick={handleSendEmail.bind(null, email)} // TO DO iconType="email" aria-label="email" /> @@ -59,12 +67,20 @@ const renderUsers = (users: ElasticUser[]) => { )); }; -export const UserList = React.memo(({ headline, users }: UserListProps) => { +export const UserList = React.memo(({ email, headline, users }: UserListProps) => { + const handleSendEmail = useCallback( + (emailAddress: string | undefined | null) => { + if (emailAddress && emailAddress != null) { + window.open(`mailto:${emailAddress}?subject=${email.subject}&body=${email.body}`, '_blank'); + } + }, + [email.subject] + ); return (

      {headline}

      - {renderUsers(users)} + {renderUsers(users, handleSendEmail)}
      ); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 6ef412d408ae5..341a34240fe49 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -30,6 +30,16 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { defaultMessage: 'Opened on', }); +export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { + defaultMessage: 'Closed on', +}); +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { + defaultMessage: 'Reopen case', +}); +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { + defaultMessage: 'Close case', +}); + export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { defaultMessage: 'Reporter', }); diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 68a222cb656ed..6f58e2702ec5b 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -24,6 +24,8 @@ export const CaseAttributesRt = rt.intersection([ CaseBasicRt, rt.type({ comment_ids: rt.array(rt.string), + closed_at: rt.union([rt.string, rt.null]), + closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, updated_at: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/user.ts b/x-pack/plugins/case/common/api/user.ts index ed44791c4e04d..651cd08f08a02 100644 --- a/x-pack/plugins/case/common/api/user.ts +++ b/x-pack/plugins/case/common/api/user.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; export const UserRT = rt.type({ + email: rt.union([rt.undefined, rt.string]), full_name: rt.union([rt.undefined, rt.string]), username: rt.string, }); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts index 17a2518482637..c08dae1dc18b4 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -13,7 +13,11 @@ function createAuthenticationMock({ authc.getCurrentUser.mockReturnValue( currentUser !== undefined ? currentUser - : ({ username: 'awesome', full_name: 'Awesome D00d' } as AuthenticatedUser) + : ({ + email: 'd00d@awesome.com', + username: 'awesome', + full_name: 'Awesome D00d', + } as AuthenticatedUser) ); return authc; } diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 1e1965f83ff68..5aa8b93f17b08 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -12,10 +12,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-1', attributes: { + closed_at: null, + closed_by: null, comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', @@ -25,6 +28,7 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T21:54:48.952Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -36,10 +40,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-2', attributes: { + closed_at: null, + closed_by: null, comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', @@ -49,6 +56,7 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T22:32:00.900Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -60,10 +68,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-3', attributes: { + closed_at: null, + closed_by: null, comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', @@ -73,6 +84,39 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [], + updated_at: '2019-11-25T22:32:17.947Z', + version: 'WzUsMV0=', + }, + { + type: 'cases', + id: 'mock-id-4', + attributes: { + closed_at: '2019-11-25T22:32:17.947Z', + closed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comment_ids: [], + created_at: '2019-11-25T22:32:17.947Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + title: 'Another bad one', + status: 'closed', + tags: ['LOLBins'], + updated_at: '2019-11-25T22:32:17.947Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -100,11 +144,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T21:55:00.177Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T21:55:00.177Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -126,11 +172,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T21:55:14.633Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T21:55:14.633Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -153,11 +201,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T22:32:30.608Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index 0166ba89eb76c..c14a94e84e51c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -56,14 +56,14 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updatedComment = await caseService.patchComment({ client: context.core.savedObjects.client, commentId: query.id, updatedAttributes: { comment: query.comment, updated_at: new Date().toISOString(), - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, version: query.version, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 1da1161ab01d1..1542394fc438d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -49,7 +49,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updateDate = new Date().toISOString(); const patch = await caseConfigureService.patch({ @@ -58,7 +58,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout updatedAttributes: { ...queryWithoutVersion, updated_at: updateDate, - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index a22dd8437e508..c839d36dcf4df 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -43,7 +43,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ); } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const creationDate = new Date().toISOString(); const post = await caseConfigureService.post({ @@ -51,7 +51,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route attributes: { ...query, created_at: creationDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts index 7ce37d2569e57..8fafb1af0eb82 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts @@ -34,6 +34,6 @@ describe('GET all cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.cases).toHaveLength(3); + expect(response.payload.cases).toHaveLength(4); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 7ab7212d2f436..19ff7f0734a77 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -25,7 +25,7 @@ describe('PATCH cases', () => { toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), })); }); - it(`Patch a case`, async () => { + it(`Close a case`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', method: 'patch', @@ -50,17 +50,61 @@ describe('PATCH cases', () => { expect(response.status).toEqual(200); expect(response.payload).toEqual([ { + closed_at: '2019-11-25T21:54:48.952Z', + closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, comment_ids: ['mock-comment-1'], comments: [], created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'elastic', username: 'elastic' }, + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', status: 'closed', tags: ['defacement'], title: 'Super Bad Security Issue', updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { full_name: 'Awesome D00d', username: 'awesome' }, + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, + version: 'WzE3LDFd', + }, + ]); + }); + it(`Open a case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-id-4', + status: 'open', + version: 'WzUsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload).toEqual([ + { + closed_at: null, + closed_by: null, + comment_ids: [], + comments: [], + created_at: '2019-11-25T22:32:17.947Z', + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + id: 'mock-id-4', + status: 'open', + tags: ['LOLBins'], + title: 'Another bad one', + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', }, ]); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 3fd8c2a1627ab..4aa0d8daf5b34 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -37,10 +37,23 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { client: context.core.savedObjects.client, caseIds: query.cases.map(q => q.id), }); + let nonExistingCases: CasePatchRequest[] = []; const conflictedCases = query.cases.filter(q => { const myCase = myCases.saved_objects.find(c => c.id === q.id); + + if (myCase && myCase.error) { + nonExistingCases = [...nonExistingCases, q]; + return false; + } return myCase == null || myCase?.version !== q.version; }); + if (nonExistingCases.length > 0) { + throw Boom.notFound( + `These cases ${nonExistingCases + .map(c => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } if (conflictedCases.length > 0) { throw Boom.conflict( `These cases ${conflictedCases @@ -60,18 +73,31 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }); if (updateFilterCases.length > 0) { const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ client: context.core.savedObjects.client, cases: updateFilterCases.map(thisCase => { const { id: caseId, version, ...updateCaseAttributes } = thisCase; + let closedInfo = {}; + if (updateCaseAttributes.status && updateCaseAttributes.status === 'closed') { + closedInfo = { + closed_at: updatedDt, + closed_by: { email, full_name, username }, + }; + } else if (updateCaseAttributes.status && updateCaseAttributes.status === 'open') { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } return { caseId, updatedAttributes: { ...updateCaseAttributes, + ...closedInfo, updated_at: updatedDt, - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, version, }; diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index eac259cc69c5a..7af3e7b70d96f 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -14,7 +14,7 @@ export interface RouteDeps { } export enum SortFieldCase { + closedAt = 'closed_at', createdAt = 'created_at', status = 'status', - updatedAt = 'updated_at', } diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 27ee6fc58e20a..19dbb024d1e0b 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -26,18 +26,22 @@ import { SortFieldCase } from './types'; export const transformNewCase = ({ createdDate, - newCase, + email, full_name, + newCase, username, }: { createdDate: string; - newCase: CaseRequest; + email?: string; full_name?: string; + newCase: CaseRequest; username: string; }): CaseAttributes => ({ + closed_at: newCase.status === 'closed' ? createdDate : null, + closed_by: newCase.status === 'closed' ? { email, full_name, username } : null, comment_ids: [], created_at: createdDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, ...newCase, @@ -46,18 +50,20 @@ export const transformNewCase = ({ interface NewCommentArgs { comment: string; createdDate: string; + email?: string; full_name?: string; username: string; } export const transformNewComment = ({ comment, createdDate, + email, full_name, username, }: NewCommentArgs): CommentAttributes => ({ comment, created_at: createdDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, }); @@ -133,9 +139,9 @@ export const sortToSnake = (sortField: string): SortFieldCase => { case 'createdAt': case 'created_at': return SortFieldCase.createdAt; - case 'updatedAt': - case 'updated_at': - return SortFieldCase.updatedAt; + case 'closedAt': + case 'closed_at': + return SortFieldCase.closedAt; default: return SortFieldCase.createdAt; } diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 2aa64528739b1..8eab040b9ca9c 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -14,6 +14,22 @@ export const caseSavedObjectType: SavedObjectsType = { namespaceAgnostic: false, mappings: { properties: { + closed_at: { + type: 'date', + }, + closed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, comment_ids: { type: 'keyword', }, @@ -28,6 +44,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, description: { @@ -53,6 +72,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 51c31421fec2f..f52da886e7611 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -28,6 +28,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { username: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, updated_at: { @@ -41,6 +44,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, }, From 304b322a47cb6f9d697a8516da23497764bb8a32 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 19 Mar 2020 17:32:39 +0100 Subject: [PATCH 10/13] [Console] Refactor and cleanup of public and server (#60513) * Clean up use of ace in autocomplete in public Remove ace from lib/autocomplete.ts and set up hooking up of ace in legacy_core_editor. Also remove use of ace mocks in tests. * Added TODO in lib/kb (console public) * Server-side cleanup Refactored the loading of spec into a new SpecDefinitionsService. In this way, state can be contained inside of the service as much as possible. Also converted all JS spec to TS and updated the Console plugin contract so that processors (which alter loaded spec) happen at plugin "start" phase. * Fix types * Small refactor - Updated naming of argument variable in registerAutocompleter - Refactored the SpecDefinitionsService to handle binding of it's own functions --- .../legacy_core_editor/legacy_core_editor.ts | 56 ++++++- .../__tests__/integration.test.js | 145 +++++++++-------- .../models/sense_editor/sense_editor.ts | 1 + .../url_autocomplete.test.js | 1 - .../url_params.test.js | 4 - .../public/lib/autocomplete/autocomplete.ts | 71 ++------- .../public/lib/autocomplete/body_completer.js | 1 - .../console/public/lib/autocomplete/engine.js | 2 +- src/plugins/console/public/lib/kb/kb.js | 4 + .../console/public/types/core_editor.ts | 12 ++ src/plugins/console/server/index.ts | 2 +- src/plugins/console/server/lib/index.ts | 2 +- .../server/lib/spec_definitions/api.js | 72 --------- .../console/server/lib/spec_definitions/es.js | 47 ------ .../{js/query/index.js => index.ts} | 2 +- .../js/{aggregations.js => aggregations.ts} | 28 ++-- .../js/{aliases.js => aliases.ts} | 8 +- .../js/{document.js => document.ts} | 12 +- .../js/{filter.js => filter.ts} | 10 +- .../js/{globals.js => globals.ts} | 11 +- .../{index.d.ts => js/index.ts} | 35 ++-- .../js/{ingest.js => ingest.ts} | 15 +- .../js/{mappings.js => mappings.ts} | 11 +- .../js/query/{dsl.js => dsl.ts} | 21 ++- .../{server.js => js/query/index.ts} | 8 +- .../js/query/{templates.js => templates.ts} | 12 ++ .../js/{reindex.js => reindex.ts} | 9 +- .../js/{search.js => search.ts} | 14 +- .../js/{settings.js => settings.ts} | 9 +- .../js/{shared.js => shared.ts} | 1 + .../server/lib/spec_definitions/json/index.js | 59 ------- src/plugins/console/server/plugin.ts | 26 +-- .../api/console/spec_definitions/index.ts | 24 ++- .../index.js => services/index.ts} | 8 +- .../services/spec_definitions_service.ts | 150 ++++++++++++++++++ src/plugins/console/server/types.ts | 5 + .../console_extensions/server/plugin.ts | 24 +-- 37 files changed, 497 insertions(+), 425 deletions(-) rename src/plugins/console/public/lib/autocomplete/{__tests__ => __jest__}/url_autocomplete.test.js (99%) rename src/plugins/console/public/lib/autocomplete/{__tests__ => __jest__}/url_params.test.js (95%) delete mode 100644 src/plugins/console/server/lib/spec_definitions/api.js delete mode 100644 src/plugins/console/server/lib/spec_definitions/es.js rename src/plugins/console/server/lib/spec_definitions/{js/query/index.js => index.ts} (94%) rename src/plugins/console/server/lib/spec_definitions/js/{aggregations.js => aggregations.ts} (95%) rename src/plugins/console/server/lib/spec_definitions/js/{aliases.js => aliases.ts} (80%) rename src/plugins/console/server/lib/spec_definitions/js/{document.js => document.ts} (85%) rename src/plugins/console/server/lib/spec_definitions/js/{filter.js => filter.ts} (94%) rename src/plugins/console/server/lib/spec_definitions/js/{globals.js => globals.ts} (85%) rename src/plugins/console/server/lib/spec_definitions/{index.d.ts => js/index.ts} (54%) rename src/plugins/console/server/lib/spec_definitions/js/{ingest.js => ingest.ts} (96%) rename src/plugins/console/server/lib/spec_definitions/js/{mappings.js => mappings.ts} (96%) rename src/plugins/console/server/lib/spec_definitions/js/query/{dsl.js => dsl.ts} (97%) rename src/plugins/console/server/lib/spec_definitions/{server.js => js/query/index.ts} (89%) rename src/plugins/console/server/lib/spec_definitions/js/query/{templates.js => templates.ts} (97%) rename src/plugins/console/server/lib/spec_definitions/js/{reindex.js => reindex.ts} (88%) rename src/plugins/console/server/lib/spec_definitions/js/{search.js => search.ts} (92%) rename src/plugins/console/server/lib/spec_definitions/js/{settings.js => settings.ts} (90%) rename src/plugins/console/server/lib/spec_definitions/js/{shared.js => shared.ts} (94%) delete mode 100644 src/plugins/console/server/lib/spec_definitions/json/index.js rename src/plugins/console/server/{lib/spec_definitions/index.js => services/index.ts} (81%) create mode 100644 src/plugins/console/server/services/spec_definitions_service.ts diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts index 47947e985092b..fc419b0f10dca 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts @@ -18,9 +18,17 @@ */ import ace from 'brace'; -import { Editor as IAceEditor } from 'brace'; +import { Editor as IAceEditor, IEditSession as IAceEditSession } from 'brace'; import $ from 'jquery'; -import { CoreEditor, Position, Range, Token, TokensProvider, EditorEvent } from '../../../types'; +import { + CoreEditor, + Position, + Range, + Token, + TokensProvider, + EditorEvent, + AutoCompleterFunction, +} from '../../../types'; import { AceTokensProvider } from '../../../lib/ace_token_provider'; import * as curl from '../sense_editor/curl'; import smartResize from './smart_resize'; @@ -354,4 +362,48 @@ export class LegacyCoreEditor implements CoreEditor { } } } + + registerAutocompleter(autocompleter: AutoCompleterFunction): void { + // Hook into Ace + + // disable standard context based autocompletion. + // @ts-ignore + ace.define('ace/autocomplete/text_completer', ['require', 'exports', 'module'], function( + require: any, + exports: any + ) { + exports.getCompletions = function( + innerEditor: any, + session: any, + pos: any, + prefix: any, + callback: any + ) { + callback(null, []); + }; + }); + + const langTools = ace.acequire('ace/ext/language_tools'); + + langTools.setCompleters([ + { + identifierRegexps: [ + /[a-zA-Z_0-9\.\$\-\u00A2-\uFFFF]/, // adds support for dot character + ], + getCompletions: ( + DO_NOT_USE_1: IAceEditor, + DO_NOT_USE_2: IAceEditSession, + pos: { row: number; column: number }, + prefix: string, + callback: (...args: any[]) => void + ) => { + const position: Position = { + lineNumber: pos.row + 1, + column: pos.column + 1, + }; + autocompleter(position, prefix, callback); + }, + }, + ]); + } } diff --git a/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js b/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js index 1a09b6b00da9c..c5a0c2ebddf71 100644 --- a/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js @@ -84,93 +84,90 @@ describe('Integration', () => { changeListener: function() {}, }; // mimic auto complete - senseEditor.autocomplete._test.getCompletions( - senseEditor, - null, - { row: cursor.lineNumber - 1, column: cursor.column - 1 }, - '', - function(err, terms) { - if (testToRun.assertThrows) { - done(); - return; - } + senseEditor.autocomplete._test.getCompletions(senseEditor, null, cursor, '', function( + err, + terms + ) { + if (testToRun.assertThrows) { + done(); + return; + } - if (err) { - throw err; - } + if (err) { + throw err; + } - if (testToRun.no_context) { - expect(!terms || terms.length === 0).toBeTruthy(); - } else { - expect(terms).not.toBeNull(); - expect(terms.length).toBeGreaterThan(0); - } + if (testToRun.no_context) { + expect(!terms || terms.length === 0).toBeTruthy(); + } else { + expect(terms).not.toBeNull(); + expect(terms.length).toBeGreaterThan(0); + } - if (!terms || terms.length === 0) { - done(); - return; - } + if (!terms || terms.length === 0) { + done(); + return; + } - if (testToRun.autoCompleteSet) { - const expectedTerms = _.map(testToRun.autoCompleteSet, function(t) { - if (typeof t !== 'object') { - t = { name: t }; - } - return t; - }); - if (terms.length !== expectedTerms.length) { - expect(_.pluck(terms, 'name')).toEqual(_.pluck(expectedTerms, 'name')); - } else { - const filteredActualTerms = _.map(terms, function(actualTerm, i) { - const expectedTerm = expectedTerms[i]; - const filteredTerm = {}; - _.each(expectedTerm, function(v, p) { - filteredTerm[p] = actualTerm[p]; - }); - return filteredTerm; - }); - expect(filteredActualTerms).toEqual(expectedTerms); + if (testToRun.autoCompleteSet) { + const expectedTerms = _.map(testToRun.autoCompleteSet, function(t) { + if (typeof t !== 'object') { + t = { name: t }; } + return t; + }); + if (terms.length !== expectedTerms.length) { + expect(_.pluck(terms, 'name')).toEqual(_.pluck(expectedTerms, 'name')); + } else { + const filteredActualTerms = _.map(terms, function(actualTerm, i) { + const expectedTerm = expectedTerms[i]; + const filteredTerm = {}; + _.each(expectedTerm, function(v, p) { + filteredTerm[p] = actualTerm[p]; + }); + return filteredTerm; + }); + expect(filteredActualTerms).toEqual(expectedTerms); } + } - const context = terms[0].context; - const { - cursor: { lineNumber, column }, - } = testToRun; - senseEditor.autocomplete._test.addReplacementInfoToContext( - context, - { lineNumber, column }, - terms[0].value - ); + const context = terms[0].context; + const { + cursor: { lineNumber, column }, + } = testToRun; + senseEditor.autocomplete._test.addReplacementInfoToContext( + context, + { lineNumber, column }, + terms[0].value + ); - function ac(prop, propTest) { - if (typeof testToRun[prop] !== 'undefined') { - if (propTest) { - propTest(context[prop], testToRun[prop], prop); - } else { - expect(context[prop]).toEqual(testToRun[prop]); - } + function ac(prop, propTest) { + if (typeof testToRun[prop] !== 'undefined') { + if (propTest) { + propTest(context[prop], testToRun[prop], prop); + } else { + expect(context[prop]).toEqual(testToRun[prop]); } } + } - function posCompare(actual, expected) { - expect(actual.lineNumber).toEqual(expected.lineNumber + lineOffset); - expect(actual.column).toEqual(expected.column); - } - - function rangeCompare(actual, expected, name) { - posCompare(actual.start, expected.start, name + '.start'); - posCompare(actual.end, expected.end, name + '.end'); - } + function posCompare(actual, expected) { + expect(actual.lineNumber).toEqual(expected.lineNumber + lineOffset); + expect(actual.column).toEqual(expected.column); + } - ac('prefixToAdd'); - ac('suffixToAdd'); - ac('addTemplate'); - ac('textBoxPosition', posCompare); - ac('rangeToReplace', rangeCompare); - done(); + function rangeCompare(actual, expected, name) { + posCompare(actual.start, expected.start, name + '.start'); + posCompare(actual.end, expected.end, name + '.end'); } - ); + + ac('prefixToAdd'); + ac('suffixToAdd'); + ac('addTemplate'); + ac('textBoxPosition', posCompare); + ac('rangeToReplace', rangeCompare); + done(); + }); }); } diff --git a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts index f559f5dfcd707..b1444bdf2bbab 100644 --- a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts +++ b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts @@ -44,6 +44,7 @@ export class SenseEditor { coreEditor, parser: this.parser, }); + this.coreEditor.registerAutocompleter(this.autocomplete.getCompletions); this.coreEditor.on( 'tokenizerUpdate', this.highlightCurrentRequestsAndUpdateActionBar.bind(this) diff --git a/src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js similarity index 99% rename from src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js rename to src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js index 40fcd551fb6f7..0758a75695566 100644 --- a/src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import '../../../application/models/sense_editor/sense_editor.test.mocks'; const _ = require('lodash'); import { diff --git a/src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js similarity index 95% rename from src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js rename to src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js index ce2a2553b19ee..72fce53c4f1fe 100644 --- a/src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js @@ -16,10 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import '../../../application/models/sense_editor/sense_editor.test.mocks'; -import 'brace'; -import 'brace/mode/javascript'; -import 'brace/mode/json'; const _ = require('lodash'); import { UrlParams } from '../../autocomplete/url_params'; import { populateContext } from '../../autocomplete/engine'; diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index e09024ccfc859..d4f10ff4e4277 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -18,9 +18,9 @@ */ import _ from 'lodash'; -import ace, { Editor as AceEditor, IEditSession } from 'brace'; import { i18n } from '@kbn/i18n'; +// TODO: All of these imports need to be moved to the core editor so that it can inject components from there. import { getTopLevelUrlCompleteComponents, getEndpointBodyCompleteComponents, @@ -39,7 +39,7 @@ import { createTokenIterator } from '../../application/factories'; import { Position, Token, Range, CoreEditor } from '../../types'; -let LAST_EVALUATED_TOKEN: any = null; +let lastEvaluatedToken: any = null; function isUrlParamsToken(token: any) { switch ((token || {}).type) { @@ -889,7 +889,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor if (!currentToken) { if (pos.lineNumber === 1) { - LAST_EVALUATED_TOKEN = null; + lastEvaluatedToken = null; return; } currentToken = { position: { column: 0, lineNumber: 0 }, value: '', type: '' }; // empty row @@ -902,26 +902,26 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor if (parser.isEmptyToken(nextToken)) { // Empty line, or we're not on the edge of current token. Save the current position as base currentToken.position.column = pos.column; - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; } else { nextToken.position.lineNumber = pos.lineNumber; - LAST_EVALUATED_TOKEN = nextToken; + lastEvaluatedToken = nextToken; } return; } - if (!LAST_EVALUATED_TOKEN) { - LAST_EVALUATED_TOKEN = currentToken; + if (!lastEvaluatedToken) { + lastEvaluatedToken = currentToken; return; // wait for the next typing. } if ( - LAST_EVALUATED_TOKEN.position.column !== currentToken.position.column || - LAST_EVALUATED_TOKEN.position.lineNumber !== currentToken.position.lineNumber || - LAST_EVALUATED_TOKEN.value === currentToken.value + lastEvaluatedToken.position.column !== currentToken.position.column || + lastEvaluatedToken.position.lineNumber !== currentToken.position.lineNumber || + lastEvaluatedToken.value === currentToken.value ) { // not on the same place or nothing changed, cache and wait for the next time - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; return; } @@ -935,7 +935,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor return; } - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; editor.execCommand('startAutocomplete'); }, 100); @@ -947,17 +947,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor } } - function getCompletions( - DO_NOT_USE: AceEditor, - DO_NOT_USE_SESSION: IEditSession, - pos: { row: number; column: number }, - prefix: string, - callback: (...args: any[]) => void - ) { - const position: Position = { - lineNumber: pos.row + 1, - column: pos.column + 1, - }; + function getCompletions(position: Position, prefix: string, callback: (...args: any[]) => void) { try { const context = getAutoCompleteContext(editor, position); if (!context) { @@ -1028,39 +1018,12 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor editor.on('changeSelection', editorChangeListener); - // Hook into Ace - - // disable standard context based autocompletion. - // @ts-ignore - ace.define('ace/autocomplete/text_completer', ['require', 'exports', 'module'], function( - require: any, - exports: any - ) { - exports.getCompletions = function( - innerEditor: any, - session: any, - pos: any, - prefix: any, - callback: any - ) { - callback(null, []); - }; - }); - - const langTools = ace.acequire('ace/ext/language_tools'); - - langTools.setCompleters([ - { - identifierRegexps: [ - /[a-zA-Z_0-9\.\$\-\u00A2-\uFFFF]/, // adds support for dot character - ], - getCompletions, - }, - ]); - return { + getCompletions, + // TODO: This needs to be cleaned up _test: { - getCompletions, + getCompletions: (_editor: any, _editSession: any, pos: any, prefix: any, callback: any) => + getCompletions(pos, prefix, callback), addReplacementInfoToContext, addChangeListener: () => editor.on('changeSelection', editorChangeListener), removeChangeListener: () => editor.off('changeSelection', editorChangeListener), diff --git a/src/plugins/console/public/lib/autocomplete/body_completer.js b/src/plugins/console/public/lib/autocomplete/body_completer.js index e23a58780a362..1aa315c50b9bf 100644 --- a/src/plugins/console/public/lib/autocomplete/body_completer.js +++ b/src/plugins/console/public/lib/autocomplete/body_completer.js @@ -115,7 +115,6 @@ class ScopeResolver extends SharedComponent { next: [], }; const components = this.resolveLinkToComponents(context, editor); - _.each(components, function(component) { const componentResult = component.match(token, context, editor); if (componentResult && componentResult.next) { diff --git a/src/plugins/console/public/lib/autocomplete/engine.js b/src/plugins/console/public/lib/autocomplete/engine.js index f4df8af871eba..7b64d91c95374 100644 --- a/src/plugins/console/public/lib/autocomplete/engine.js +++ b/src/plugins/console/public/lib/autocomplete/engine.js @@ -43,7 +43,7 @@ export function wrapComponentWithDefaults(component, defaults) { const tracer = function() { if (window.engine_trace) { - console.log.call(console, arguments); + console.log.call(console, ...arguments); } }; diff --git a/src/plugins/console/public/lib/kb/kb.js b/src/plugins/console/public/lib/kb/kb.js index 053b82bd81d0a..ef921fa7f476e 100644 --- a/src/plugins/console/public/lib/kb/kb.js +++ b/src/plugins/console/public/lib/kb/kb.js @@ -146,6 +146,10 @@ function loadApisFromJson( return api; } +// TODO: clean up setting up of active API and use of jQuery. +// This function should be attached to a class that holds the current state, not setup +// when the file is required. Also, jQuery should not be used to make network requests +// like this, it looks like a minor security issue. export function setActiveApi(api) { if (!api) { $.ajax({ diff --git a/src/plugins/console/public/types/core_editor.ts b/src/plugins/console/public/types/core_editor.ts index 79dc3ca74200b..b71f4fff44ca5 100644 --- a/src/plugins/console/public/types/core_editor.ts +++ b/src/plugins/console/public/types/core_editor.ts @@ -29,6 +29,12 @@ export type EditorEvent = | 'change' | 'changeSelection'; +export type AutoCompleterFunction = ( + pos: Position, + prefix: string, + callback: (...args: any[]) => void +) => void; + export interface Position { /** * The line number, not zero-indexed. @@ -256,4 +262,10 @@ export interface CoreEditor { * Register a keyboard shortcut and provide a function to be called. */ registerKeyboardShortcut(opts: { keys: any; fn: () => void; name: string }): void; + + /** + * Register a completions function that will be called when the editor + * detects a change + */ + registerAutocompleter(autocompleter: AutoCompleterFunction): void; } diff --git a/src/plugins/console/server/index.ts b/src/plugins/console/server/index.ts index b603deee12e23..62e5bd6bf8d95 100644 --- a/src/plugins/console/server/index.ts +++ b/src/plugins/console/server/index.ts @@ -21,7 +21,7 @@ import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server' import { ConfigType, config as configSchema } from './config'; import { ConsoleServerPlugin } from './plugin'; -export { ConsoleSetup } from './types'; +export { ConsoleSetup, ConsoleStart } from './types'; export const plugin = (ctx: PluginInitializerContext) => new ConsoleServerPlugin(ctx); diff --git a/src/plugins/console/server/lib/index.ts b/src/plugins/console/server/lib/index.ts index 2347084b73a66..0c8fc125874cf 100644 --- a/src/plugins/console/server/lib/index.ts +++ b/src/plugins/console/server/lib/index.ts @@ -22,4 +22,4 @@ export { ProxyConfigCollection } from './proxy_config_collection'; export { proxyRequest } from './proxy_request'; export { getElasticsearchProxyConfig } from './elasticsearch_proxy_config'; export { setHeaders } from './set_headers'; -export { addProcessorDefinition, addExtensionSpecFilePath, loadSpec } from './spec_definitions'; +export { jsSpecLoaders } from './spec_definitions'; diff --git a/src/plugins/console/server/lib/spec_definitions/api.js b/src/plugins/console/server/lib/spec_definitions/api.js deleted file mode 100644 index 9c3835013bce9..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/api.js +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; - -class Api { - constructor(name) { - this.globalRules = {}; - this.endpoints = {}; - this.name = name; - } - - addGlobalAutocompleteRules = (parentNode, rules) => { - this.globalRules[parentNode] = rules; - }; - - addEndpointDescription = (endpoint, description = {}) => { - let copiedDescription = {}; - if (this.endpoints[endpoint]) { - copiedDescription = { ...this.endpoints[endpoint] }; - } - let urlParamsDef; - _.each(description.patterns || [], function(p) { - if (p.indexOf('{indices}') >= 0) { - urlParamsDef = urlParamsDef || {}; - urlParamsDef.ignore_unavailable = '__flag__'; - urlParamsDef.allow_no_indices = '__flag__'; - urlParamsDef.expand_wildcards = ['open', 'closed']; - } - }); - - if (urlParamsDef) { - description.url_params = _.extend(description.url_params || {}, copiedDescription.url_params); - _.defaults(description.url_params, urlParamsDef); - } - - _.extend(copiedDescription, description); - _.defaults(copiedDescription, { - id: endpoint, - patterns: [endpoint], - methods: ['GET'], - }); - - this.endpoints[endpoint] = copiedDescription; - }; - - asJson() { - return { - name: this.name, - globals: this.globalRules, - endpoints: this.endpoints, - }; - } -} - -export default Api; diff --git a/src/plugins/console/server/lib/spec_definitions/es.js b/src/plugins/console/server/lib/spec_definitions/es.js deleted file mode 100644 index fc24a64f8a6f4..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/es.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Api from './api'; -import { getSpec } from './json'; -import { register } from './js/ingest'; -const ES = new Api('es'); - -export const loadSpec = () => { - const spec = getSpec(); - - // adding generated specs - Object.keys(spec).forEach(endpoint => { - ES.addEndpointDescription(endpoint, spec[endpoint]); - }); - - // adding globals and custom API definitions - require('./js/aliases')(ES); - require('./js/aggregations')(ES); - require('./js/document')(ES); - require('./js/filter')(ES); - require('./js/globals')(ES); - register(ES); - require('./js/mappings')(ES); - require('./js/settings')(ES); - require('./js/query')(ES); - require('./js/reindex')(ES); - require('./js/search')(ES); -}; - -export default ES; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/index.js b/src/plugins/console/server/lib/spec_definitions/index.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/query/index.js rename to src/plugins/console/server/lib/spec_definitions/index.ts index cbe4e7ed2dd5f..7c70c406d8c22 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/index.js +++ b/src/plugins/console/server/lib/spec_definitions/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { queryDsl as default } from './dsl'; +export { jsSpecLoaders } from './js'; diff --git a/src/plugins/console/server/lib/spec_definitions/js/aggregations.js b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts similarity index 95% rename from src/plugins/console/server/lib/spec_definitions/js/aggregations.js rename to src/plugins/console/server/lib/spec_definitions/js/aggregations.ts index 629e143aa2b43..1170c9edd2366 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/aggregations.js +++ b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts @@ -16,8 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -/*eslint camelcase: 0*/ +/* eslint-disable @typescript-eslint/camelcase */ const significantTermsArgs = { __template: { field: '', @@ -77,7 +78,7 @@ const simple_pipeline = { }, buckets_path: '', format: '', - gap_policy: gap_policy, + gap_policy, }; const rules = { '*': { @@ -461,7 +462,7 @@ const rules = { }, buckets_path: '', format: '', - gap_policy: gap_policy, + gap_policy, window: 5, model: { __one_of: ['simple', 'linear', 'ewma', 'holt', 'holt_winters'] }, settings: { @@ -485,7 +486,7 @@ const rules = { lag: 7, }, lag: 7, - gap_policy: gap_policy, + gap_policy, buckets_path: '', format: '', }, @@ -496,7 +497,7 @@ const rules = { }, buckets_path: {}, format: '', - gap_policy: gap_policy, + gap_policy, script: '', }, bucket_selector: { @@ -505,7 +506,7 @@ const rules = { script: '', }, buckets_path: {}, - gap_policy: gap_policy, + gap_policy, script: '', }, bucket_sort: { @@ -515,7 +516,7 @@ const rules = { sort: ['{field}'], from: 0, size: 0, - gap_policy: gap_policy, + gap_policy, }, matrix_stats: { __template: { @@ -526,8 +527,11 @@ const rules = { }, }; const { terms, histogram, date_histogram } = rules['*']; -export default function(api) { - api.addGlobalAutocompleteRules('aggregations', rules); - api.addGlobalAutocompleteRules('aggs', rules); - api.addGlobalAutocompleteRules('groupByAggs', { '*': { terms, histogram, date_histogram } }); -} + +export const aggs = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('aggregations', rules); + specService.addGlobalAutocompleteRules('aggs', rules); + specService.addGlobalAutocompleteRules('groupByAggs', { + '*': { terms, histogram, date_histogram }, + }); +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/aliases.js b/src/plugins/console/server/lib/spec_definitions/js/aliases.ts similarity index 80% rename from src/plugins/console/server/lib/spec_definitions/js/aliases.js rename to src/plugins/console/server/lib/spec_definitions/js/aliases.ts index f46713fb8dd3f..c7d51b70ab3e3 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/aliases.js +++ b/src/plugins/console/server/lib/spec_definitions/js/aliases.ts @@ -16,15 +16,17 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { +/* eslint-disable @typescript-eslint/camelcase */ +export const aliases = (specService: SpecDefinitionsService) => { const aliasRules = { filter: {}, routing: '1', search_routing: '1,2', index_routing: '1', }; - api.addGlobalAutocompleteRules('aliases', { + specService.addGlobalAutocompleteRules('aliases', { '*': aliasRules, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/document.js b/src/plugins/console/server/lib/spec_definitions/js/document.ts similarity index 85% rename from src/plugins/console/server/lib/spec_definitions/js/document.js rename to src/plugins/console/server/lib/spec_definitions/js/document.ts index 2bdaa2ec2af9b..f8214faab2681 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/document.js +++ b/src/plugins/console/server/lib/spec_definitions/js/document.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { - api.addEndpointDescription('update', { +/* eslint-disable @typescript-eslint/camelcase */ +export const document = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('update', { data_autocomplete_rules: { script: { // populated by a global rule @@ -29,7 +31,7 @@ export default function(api) { }, }); - api.addEndpointDescription('put_script', { + specService.addEndpointDescription('put_script', { methods: ['POST', 'PUT'], patterns: ['_scripts/{lang}/{id}', '_scripts/{lang}/{id}/_create'], url_components: { @@ -40,7 +42,7 @@ export default function(api) { }, }); - api.addEndpointDescription('termvectors', { + specService.addEndpointDescription('termvectors', { data_autocomplete_rules: { fields: ['{field}'], offsets: { __one_of: [false, true] }, @@ -68,4 +70,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/filter.js b/src/plugins/console/server/lib/spec_definitions/js/filter.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/filter.js rename to src/plugins/console/server/lib/spec_definitions/js/filter.ts index bf669cff788e8..27e02f7cf1837 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/filter.js +++ b/src/plugins/console/server/lib/spec_definitions/js/filter.ts @@ -16,8 +16,10 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -const filters = {}; +/* eslint-disable @typescript-eslint/camelcase */ +const filters: Record = {}; filters.and = { __template: { @@ -324,6 +326,6 @@ filters.nested = { _name: '', }; -export default function(api) { - api.addGlobalAutocompleteRules('filter', filters); -} +export const filter = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('filter', filters); +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/globals.js b/src/plugins/console/server/lib/spec_definitions/js/globals.ts similarity index 85% rename from src/plugins/console/server/lib/spec_definitions/js/globals.js rename to src/plugins/console/server/lib/spec_definitions/js/globals.ts index 316a76c8c9434..32e1957f74d0b 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/globals.js +++ b/src/plugins/console/server/lib/spec_definitions/js/globals.ts @@ -16,7 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; +/* eslint-disable @typescript-eslint/camelcase */ const highlightOptions = { boundary_chars: {}, boundary_max_scan: 20, @@ -48,8 +50,9 @@ const highlightOptions = { }, tags_schema: {}, }; -export default function(api) { - api.addGlobalAutocompleteRules('highlight', { + +export const globals = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('highlight', { ...highlightOptions, fields: { '{field}': { @@ -60,7 +63,7 @@ export default function(api) { }, }); - api.addGlobalAutocompleteRules('script', { + specService.addGlobalAutocompleteRules('script', { __template: { source: 'SCRIPT', }, @@ -70,4 +73,4 @@ export default function(api) { lang: '', params: {}, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/index.d.ts b/src/plugins/console/server/lib/spec_definitions/js/index.ts similarity index 54% rename from src/plugins/console/server/lib/spec_definitions/index.d.ts rename to src/plugins/console/server/lib/spec_definitions/js/index.ts index da0125a186c15..234ccd22aaa8b 100644 --- a/src/plugins/console/server/lib/spec_definitions/index.d.ts +++ b/src/plugins/console/server/lib/spec_definitions/js/index.ts @@ -17,15 +17,30 @@ * under the License. */ -export declare function addProcessorDefinition(...args: any[]): any; +import { SpecDefinitionsService } from '../../../services'; -export declare function resolveApi(): object; +import { aggs } from './aggregations'; +import { aliases } from './aliases'; +import { document } from './document'; +import { filter } from './filter'; +import { globals } from './globals'; +import { ingest } from './ingest'; +import { mappings } from './mappings'; +import { settings } from './settings'; +import { query } from './query'; +import { reindex } from './reindex'; +import { search } from './search'; -export declare function addExtensionSpecFilePath(...args: any[]): any; - -/** - * A function that synchronously reads files JSON from disk and builds - * the autocomplete structures served to the client. This must be called - * after any extensions have been loaded. - */ -export declare function loadSpec(): any; +export const jsSpecLoaders: Array<(registry: SpecDefinitionsService) => void> = [ + aggs, + aliases, + document, + filter, + globals, + ingest, + mappings, + settings, + query, + reindex, + search, +]; diff --git a/src/plugins/console/server/lib/spec_definitions/js/ingest.js b/src/plugins/console/server/lib/spec_definitions/js/ingest.ts similarity index 96% rename from src/plugins/console/server/lib/spec_definitions/js/ingest.js rename to src/plugins/console/server/lib/spec_definitions/js/ingest.ts index edc9cc7b3e45c..1182dc075f42f 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/ingest.js +++ b/src/plugins/console/server/lib/spec_definitions/js/ingest.ts @@ -17,6 +17,9 @@ * under the License. */ +import { SpecDefinitionsService } from '../../../services'; + +/* eslint-disable @typescript-eslint/camelcase */ const commonPipelineParams = { on_failure: [], ignore_failure: { @@ -427,27 +430,23 @@ const pipelineDefinition = { version: 123, }; -export const register = api => { +export const ingest = (specService: SpecDefinitionsService) => { // Note: this isn't an actual API endpoint. It exists so the forEach processor's "processor" field // may recursively use the autocomplete rules for any processor. - api.addEndpointDescription('_processor', { + specService.addEndpointDescription('_processor', { data_autocomplete_rules: processorDefinition, }); - api.addEndpointDescription('ingest.put_pipeline', { + specService.addEndpointDescription('ingest.put_pipeline', { methods: ['PUT'], patterns: ['_ingest/pipeline/{id}'], data_autocomplete_rules: pipelineDefinition, }); - api.addEndpointDescription('ingest.simulate', { + specService.addEndpointDescription('ingest.simulate', { data_autocomplete_rules: { pipeline: pipelineDefinition, docs: [], }, }); }; - -export const addProcessorDefinition = processor => { - processorDefinition.__one_of.push(processor); -}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/mappings.js b/src/plugins/console/server/lib/spec_definitions/js/mappings.ts similarity index 96% rename from src/plugins/console/server/lib/spec_definitions/js/mappings.js rename to src/plugins/console/server/lib/spec_definitions/js/mappings.ts index 5884d14d4dc8b..8491bc17a2ff6 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/mappings.js +++ b/src/plugins/console/server/lib/spec_definitions/js/mappings.ts @@ -17,12 +17,15 @@ * under the License. */ -const _ = require('lodash'); +import _ from 'lodash'; + +import { SpecDefinitionsService } from '../../../services'; import { BOOLEAN } from './shared'; -export default function(api) { - api.addEndpointDescription('put_mapping', { +/* eslint-disable @typescript-eslint/camelcase */ +export const mappings = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('put_mapping', { priority: 10, // collides with put doc by id data_autocomplete_rules: { __template: { @@ -249,4 +252,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/dsl.js b/src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts similarity index 97% rename from src/plugins/console/server/lib/spec_definitions/js/query/dsl.js rename to src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts index 16b952fe0fe4f..d6e5030fb6928 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/dsl.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts @@ -18,6 +18,9 @@ */ import _ from 'lodash'; + +import { SpecDefinitionsService } from '../../../../services'; + import { spanFirstTemplate, spanNearTemplate, @@ -32,6 +35,8 @@ import { rangeTemplate, regexpTemplate, } from './templates'; + +/* eslint-disable @typescript-eslint/camelcase */ const matchOptions = { cutoff_frequency: 0.001, query: '', @@ -57,6 +62,7 @@ const matchOptions = { prefix_length: 1, minimum_should_match: 1, }; + const innerHits = { docvalue_fields: ['FIELD'], from: {}, @@ -84,6 +90,7 @@ const innerHits = { __one_of: ['true', 'false'], }, }; + const SPAN_QUERIES_NO_FIELD_MASK = { // TODO add one_of for objects span_first: { @@ -115,6 +122,7 @@ const SPAN_QUERIES_NO_FIELD_MASK = { __scope_link: '.span_within', }, }; + const SPAN_QUERIES = { ...SPAN_QUERIES_NO_FIELD_MASK, field_masking_span: { @@ -165,13 +173,14 @@ const DECAY_FUNC_DESC = { decay: 0.5, }, }; + const SCORING_FUNCS = { script_score: { __template: { script: "_score * doc['f'].value", }, script: { - //populated by a global rule + // populated by a global rule }, }, boost_factor: 2.0, @@ -204,8 +213,8 @@ const SCORING_FUNCS = { }, }; -export function queryDsl(api) { - api.addGlobalAutocompleteRules('query', { +export const query = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('query', { match: { __template: { FIELD: 'TEXT', @@ -631,7 +640,7 @@ export function queryDsl(api) { filter: {}, boost: 2.0, script: { - //populated by a global rule + // populated by a global rule }, }, ], @@ -695,7 +704,7 @@ export function queryDsl(api) { script: "_score * doc['f'].value", }, script: { - //populated by a global rule + // populated by a global rule }, }, wrapper: { @@ -705,4 +714,4 @@ export function queryDsl(api) { query: '', }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/server.js b/src/plugins/console/server/lib/spec_definitions/js/query/index.ts similarity index 89% rename from src/plugins/console/server/lib/spec_definitions/server.js rename to src/plugins/console/server/lib/spec_definitions/js/query/index.ts index cb855958d403a..f4f896fd7814c 100644 --- a/src/plugins/console/server/lib/spec_definitions/server.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/index.ts @@ -17,10 +17,4 @@ * under the License. */ -import es from './es'; - -export function resolveApi() { - return { - es: es.asJson(), - }; -} +export { query } from './dsl'; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/templates.js b/src/plugins/console/server/lib/spec_definitions/js/query/templates.ts similarity index 97% rename from src/plugins/console/server/lib/spec_definitions/js/query/templates.js rename to src/plugins/console/server/lib/spec_definitions/js/query/templates.ts index 9b6311bf5712e..60192f81fec80 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/templates.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/templates.ts @@ -17,23 +17,28 @@ * under the License. */ +/* eslint-disable @typescript-eslint/camelcase */ export const regexpTemplate = { FIELD: 'REGEXP', }; + export const fuzzyTemplate = { FIELD: {}, }; + export const prefixTemplate = { FIELD: { value: '', }, }; + export const rangeTemplate = { FIELD: { gte: 10, lte: 20, }, }; + export const spanFirstTemplate = { match: { span_term: { @@ -42,6 +47,7 @@ export const spanFirstTemplate = { }, end: 3, }; + export const spanNearTemplate = { clauses: [ { @@ -55,11 +61,13 @@ export const spanNearTemplate = { slop: 12, in_order: false, }; + export const spanTermTemplate = { FIELD: { value: 'VALUE', }, }; + export const spanNotTemplate = { include: { span_term: { @@ -76,6 +84,7 @@ export const spanNotTemplate = { }, }, }; + export const spanOrTemplate = { clauses: [ { @@ -87,6 +96,7 @@ export const spanOrTemplate = { }, ], }; + export const spanContainingTemplate = { little: { span_term: { @@ -118,6 +128,7 @@ export const spanContainingTemplate = { }, }, }; + export const spanWithinTemplate = { little: { span_term: { @@ -149,6 +160,7 @@ export const spanWithinTemplate = { }, }, }; + export const wildcardTemplate = { FIELD: { value: 'VALUE', diff --git a/src/plugins/console/server/lib/spec_definitions/js/reindex.js b/src/plugins/console/server/lib/spec_definitions/js/reindex.ts similarity index 88% rename from src/plugins/console/server/lib/spec_definitions/js/reindex.js rename to src/plugins/console/server/lib/spec_definitions/js/reindex.ts index 45163d2b3c4c3..862a4323f7bf3 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/reindex.js +++ b/src/plugins/console/server/lib/spec_definitions/js/reindex.ts @@ -17,8 +17,11 @@ * under the License. */ -export default function(api) { - api.addEndpointDescription('reindex', { +import { SpecDefinitionsService } from '../../../services'; + +/* eslint-disable @typescript-eslint/camelcase */ +export const reindex = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('reindex', { methods: ['POST'], patterns: ['_reindex'], data_autocomplete_rules: { @@ -62,4 +65,4 @@ export default function(api) { script: { __scope_link: 'GLOBAL.script' }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/search.js b/src/plugins/console/server/lib/spec_definitions/js/search.ts similarity index 92% rename from src/plugins/console/server/lib/spec_definitions/js/search.js rename to src/plugins/console/server/lib/spec_definitions/js/search.ts index 19ce30d9929a5..e319870d7be5c 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/search.js +++ b/src/plugins/console/server/lib/spec_definitions/js/search.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { - api.addEndpointDescription('search', { +/* eslint-disable @typescript-eslint/camelcase */ +export const search = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('search', { priority: 10, // collides with get doc by id data_autocomplete_rules: { query: { @@ -191,7 +193,7 @@ export default function(api) { }, }); - api.addEndpointDescription('search_template', { + specService.addEndpointDescription('search_template', { data_autocomplete_rules: { template: { __one_of: [{ __scope_link: 'search' }, { __scope_link: 'GLOBAL.script' }], @@ -200,18 +202,18 @@ export default function(api) { }, }); - api.addEndpointDescription('render_search_template', { + specService.addEndpointDescription('render_search_template', { data_autocomplete_rules: { __one_of: [{ source: { __scope_link: 'search' } }, { __scope_link: 'GLOBAL.script' }], params: {}, }, }); - api.addEndpointDescription('_search/template/{id}', { + specService.addEndpointDescription('_search/template/{id}', { data_autocomplete_rules: { template: { __scope_link: 'search', }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/settings.js b/src/plugins/console/server/lib/spec_definitions/js/settings.ts similarity index 90% rename from src/plugins/console/server/lib/spec_definitions/js/settings.js rename to src/plugins/console/server/lib/spec_definitions/js/settings.ts index 26cd0987c34a5..88c58e618533b 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/settings.js +++ b/src/plugins/console/server/lib/spec_definitions/js/settings.ts @@ -16,11 +16,12 @@ * specific language governing permissions and limitations * under the License. */ - +import { SpecDefinitionsService } from '../../../services'; import { BOOLEAN } from './shared'; -export default function(api) { - api.addEndpointDescription('put_settings', { +/* eslint-disable @typescript-eslint/camelcase */ +export const settings = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('put_settings', { data_autocomplete_rules: { refresh_interval: '1s', number_of_shards: 1, @@ -71,4 +72,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/shared.js b/src/plugins/console/server/lib/spec_definitions/js/shared.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/shared.js rename to src/plugins/console/server/lib/spec_definitions/js/shared.ts index ace189e2d0913..a884e1aebe2e7 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/shared.js +++ b/src/plugins/console/server/lib/spec_definitions/js/shared.ts @@ -17,6 +17,7 @@ * under the License. */ +/* eslint-disable @typescript-eslint/camelcase */ export const BOOLEAN = Object.freeze({ __one_of: [true, false], }); diff --git a/src/plugins/console/server/lib/spec_definitions/json/index.js b/src/plugins/console/server/lib/spec_definitions/json/index.js deleted file mode 100644 index 19f075e897dbb..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/json/index.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import glob from 'glob'; -import { join, basename } from 'path'; -import { readFileSync } from 'fs'; -import { merge } from 'lodash'; - -const extensionSpecFilePaths = []; -function _getSpec(dirname = __dirname) { - const generatedFiles = glob.sync(join(dirname, 'generated', '*.json')); - const overrideFiles = glob.sync(join(dirname, 'overrides', '*.json')); - - return generatedFiles.reduce((acc, file) => { - const overrideFile = overrideFiles.find(f => basename(f) === basename(file)); - const loadedSpec = JSON.parse(readFileSync(file, 'utf8')); - if (overrideFile) { - merge(loadedSpec, JSON.parse(readFileSync(overrideFile, 'utf8'))); - } - const spec = {}; - Object.entries(loadedSpec).forEach(([key, value]) => { - if (acc[key]) { - // add time to remove key collision - spec[`${key}${Date.now()}`] = value; - } else { - spec[key] = value; - } - }); - - return { ...acc, ...spec }; - }, {}); -} -export function getSpec() { - const result = _getSpec(); - extensionSpecFilePaths.forEach(extensionSpecFilePath => { - merge(result, _getSpec(extensionSpecFilePath)); - }); - return result; -} - -export function addExtensionSpecFilePath(extensionSpecFilePath) { - extensionSpecFilePaths.push(extensionSpecFilePath); -} diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index 1954918f4d74f..85b728ea83891 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -21,20 +21,18 @@ import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/serv import { readLegacyEsConfig } from '../../../legacy/core_plugins/console_legacy'; -import { - ProxyConfigCollection, - addExtensionSpecFilePath, - addProcessorDefinition, - loadSpec, -} from './lib'; +import { ProxyConfigCollection } from './lib'; +import { SpecDefinitionsService } from './services'; import { ConfigType } from './config'; import { registerProxyRoute } from './routes/api/console/proxy'; import { registerSpecDefinitionsRoute } from './routes/api/console/spec_definitions'; -import { ESConfigForProxy, ConsoleSetup } from './types'; +import { ESConfigForProxy, ConsoleSetup, ConsoleStart } from './types'; -export class ConsoleServerPlugin implements Plugin { +export class ConsoleServerPlugin implements Plugin { log: Logger; + specDefinitionsService = new SpecDefinitionsService(); + constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } @@ -72,15 +70,19 @@ export class ConsoleServerPlugin implements Plugin { router, }); - registerSpecDefinitionsRoute({ router }); + registerSpecDefinitionsRoute({ + router, + services: { specDefinitions: this.specDefinitionsService }, + }); return { - addExtensionSpecFilePath, - addProcessorDefinition, + ...this.specDefinitionsService.setup(), }; } start() { - loadSpec(); + return { + ...this.specDefinitionsService.start(), + }; } } diff --git a/src/plugins/console/server/routes/api/console/spec_definitions/index.ts b/src/plugins/console/server/routes/api/console/spec_definitions/index.ts index 88bc250bbfce6..5c7e679cd0d35 100644 --- a/src/plugins/console/server/routes/api/console/spec_definitions/index.ts +++ b/src/plugins/console/server/routes/api/console/spec_definitions/index.ts @@ -17,12 +17,30 @@ * under the License. */ import { IRouter, RequestHandler } from 'kibana/server'; -import { resolveApi } from '../../../../lib/spec_definitions'; +import { SpecDefinitionsService } from '../../../../services'; -export const registerSpecDefinitionsRoute = ({ router }: { router: IRouter }) => { +interface SpecDefinitionsRouteResponse { + es: { + name: string; + globals: Record; + endpoints: Record; + }; +} + +export const registerSpecDefinitionsRoute = ({ + router, + services, +}: { + router: IRouter; + services: { specDefinitions: SpecDefinitionsService }; +}) => { const handler: RequestHandler = async (ctx, request, response) => { + const specResponse: SpecDefinitionsRouteResponse = { + es: services.specDefinitions.asJson(), + }; + return response.ok({ - body: resolveApi(), + body: specResponse, headers: { 'Content-Type': 'application/json', }, diff --git a/src/plugins/console/server/lib/spec_definitions/index.js b/src/plugins/console/server/services/index.ts similarity index 81% rename from src/plugins/console/server/lib/spec_definitions/index.js rename to src/plugins/console/server/services/index.ts index abf55639fbee8..c8dfeccd23070 100644 --- a/src/plugins/console/server/lib/spec_definitions/index.js +++ b/src/plugins/console/server/services/index.ts @@ -17,10 +17,4 @@ * under the License. */ -export { addProcessorDefinition } from './js/ingest'; - -export { addExtensionSpecFilePath } from './json'; - -export { loadSpec } from './es'; - -export { resolveApi } from './server'; +export { SpecDefinitionsService } from './spec_definitions_service'; diff --git a/src/plugins/console/server/services/spec_definitions_service.ts b/src/plugins/console/server/services/spec_definitions_service.ts new file mode 100644 index 0000000000000..39a8d5094bd5c --- /dev/null +++ b/src/plugins/console/server/services/spec_definitions_service.ts @@ -0,0 +1,150 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _, { merge } from 'lodash'; +import glob from 'glob'; +import { basename, join, resolve } from 'path'; +import { readFileSync } from 'fs'; + +import { jsSpecLoaders } from '../lib'; + +const PATH_TO_OSS_JSON_SPEC = resolve(__dirname, '../lib/spec_definitions/json'); + +export class SpecDefinitionsService { + private readonly name = 'es'; + + private readonly globalRules: Record = {}; + private readonly endpoints: Record = {}; + private readonly extensionSpecFilePaths: string[] = []; + + private hasLoadedSpec = false; + + public addGlobalAutocompleteRules(parentNode: string, rules: any) { + this.globalRules[parentNode] = rules; + } + + public addEndpointDescription(endpoint: string, description: any = {}) { + let copiedDescription: any = {}; + if (this.endpoints[endpoint]) { + copiedDescription = { ...this.endpoints[endpoint] }; + } + let urlParamsDef: any; + _.each(description.patterns || [], function(p) { + if (p.indexOf('{indices}') >= 0) { + urlParamsDef = urlParamsDef || {}; + urlParamsDef.ignore_unavailable = '__flag__'; + urlParamsDef.allow_no_indices = '__flag__'; + urlParamsDef.expand_wildcards = ['open', 'closed']; + } + }); + + if (urlParamsDef) { + description.url_params = _.extend(description.url_params || {}, copiedDescription.url_params); + _.defaults(description.url_params, urlParamsDef); + } + + _.extend(copiedDescription, description); + _.defaults(copiedDescription, { + id: endpoint, + patterns: [endpoint], + methods: ['GET'], + }); + + this.endpoints[endpoint] = copiedDescription; + } + + public asJson() { + return { + name: this.name, + globals: this.globalRules, + endpoints: this.endpoints, + }; + } + + public addExtensionSpecFilePath(path: string) { + this.extensionSpecFilePaths.push(path); + } + + public addProcessorDefinition(processor: any) { + if (!this.hasLoadedSpec) { + throw new Error( + 'Cannot add a processor definition because spec definitions have not loaded!' + ); + } + this.endpoints._processor!.data_autocomplete_rules.__one_of.push(processor); + } + + public setup() { + return { + addExtensionSpecFilePath: this.addExtensionSpecFilePath.bind(this), + }; + } + + public start() { + if (!this.hasLoadedSpec) { + this.loadJsonSpec(); + this.loadJSSpec(); + this.hasLoadedSpec = true; + return { + addProcessorDefinition: this.addProcessorDefinition.bind(this), + }; + } else { + throw new Error('Service has already started!'); + } + } + + private loadJSONSpecInDir(dirname: string) { + const generatedFiles = glob.sync(join(dirname, 'generated', '*.json')); + const overrideFiles = glob.sync(join(dirname, 'overrides', '*.json')); + + return generatedFiles.reduce((acc, file) => { + const overrideFile = overrideFiles.find(f => basename(f) === basename(file)); + const loadedSpec = JSON.parse(readFileSync(file, 'utf8')); + if (overrideFile) { + merge(loadedSpec, JSON.parse(readFileSync(overrideFile, 'utf8'))); + } + const spec: any = {}; + Object.entries(loadedSpec).forEach(([key, value]) => { + if (acc[key]) { + // add time to remove key collision + spec[`${key}${Date.now()}`] = value; + } else { + spec[key] = value; + } + }); + + return { ...acc, ...spec }; + }, {} as any); + } + + private loadJsonSpec() { + const result = this.loadJSONSpecInDir(PATH_TO_OSS_JSON_SPEC); + this.extensionSpecFilePaths.forEach(extensionSpecFilePath => { + merge(result, this.loadJSONSpecInDir(extensionSpecFilePath)); + }); + + Object.keys(result).forEach(endpoint => { + this.addEndpointDescription(endpoint, result[endpoint]); + }); + } + + private loadJSSpec() { + jsSpecLoaders.forEach(addJsSpec => addJsSpec(this)); + } +} diff --git a/src/plugins/console/server/types.ts b/src/plugins/console/server/types.ts index adafcd4d30526..4f026555ada7b 100644 --- a/src/plugins/console/server/types.ts +++ b/src/plugins/console/server/types.ts @@ -25,6 +25,11 @@ export type ConsoleSetup = ReturnType extends Prom ? U : ReturnType; +/** @public */ +export type ConsoleStart = ReturnType extends Promise + ? U + : ReturnType; + /** @internal */ export interface ESConfigForProxy { hosts: string[]; diff --git a/x-pack/plugins/console_extensions/server/plugin.ts b/x-pack/plugins/console_extensions/server/plugin.ts index f4c41aa0a0ad5..8c2cb4d0db42b 100644 --- a/x-pack/plugins/console_extensions/server/plugin.ts +++ b/x-pack/plugins/console_extensions/server/plugin.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { join } from 'path'; -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; -import { ConsoleSetup } from '../../../../src/plugins/console/server'; +import { ConsoleSetup, ConsoleStart } from '../../../../src/plugins/console/server'; import { processors } from './spec/ingest/index'; @@ -14,19 +14,25 @@ interface SetupDependencies { console: ConsoleSetup; } +interface StartDependencies { + console: ConsoleStart; +} + +const CONSOLE_XPACK_JSON_SPEC_PATH = join(__dirname, 'spec/'); + export class ConsoleExtensionsServerPlugin implements Plugin { log: Logger; constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } - setup( - core: CoreSetup, - { console: { addProcessorDefinition, addExtensionSpecFilePath } }: SetupDependencies - ) { - addExtensionSpecFilePath(join(__dirname, 'spec/')); + setup(core: CoreSetup, { console: { addExtensionSpecFilePath } }: SetupDependencies) { + addExtensionSpecFilePath(CONSOLE_XPACK_JSON_SPEC_PATH); + this.log.debug(`Added extension path to ${CONSOLE_XPACK_JSON_SPEC_PATH}...`); + } + + start(core: CoreStart, { console: { addProcessorDefinition } }: StartDependencies) { processors.forEach(processor => addProcessorDefinition(processor)); - this.log.debug('Installed console autocomplete extensions.'); + this.log.debug('Added processor definition extensions.'); } - start() {} } From a0730f795152ad251ef277e7f5c8de8c42040dd1 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 19 Mar 2020 16:42:53 +0000 Subject: [PATCH 11/13] [ML] Fixing file data visualizer override arguments (#60627) --- .../datavisualizer/file_based/components/utils/utils.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js index 3bf128f84aa78..39cd25ba87d8c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js @@ -66,6 +66,10 @@ export function createUrlOverrides(overrides, originalSettings) { ) { formattedOverrides.format = originalSettings.format; } + + if (Array.isArray(formattedOverrides.column_names)) { + formattedOverrides.column_names = formattedOverrides.column_names.join(); + } } if (formattedOverrides.format === '' && originalSettings.format === 'semi_structured_text') { @@ -82,11 +86,6 @@ export function createUrlOverrides(overrides, originalSettings) { formattedOverrides.column_names = ''; } - // escape grok pattern as it can contain bad characters - if (formattedOverrides.grok_pattern !== '') { - formattedOverrides.grok_pattern = encodeURIComponent(formattedOverrides.grok_pattern); - } - if (formattedOverrides.lines_to_sample === '') { formattedOverrides.lines_to_sample = overrides.linesToSample; } From fcf439625ba6934dcedd31338178c391d8270364 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Thu, 19 Mar 2020 12:50:05 -0400 Subject: [PATCH 12/13] [Uptime] Add Alerting UI (#57919) * WIP trying things. Add new alert type for Uptime. Add defensive checks to alert executor. Move status check code to dedicated adapter function. Clean up code. * Port adapter function to dedicated file. * WIP. * Working on parameter selection. * Selector expressions working. * Working on actions. * Change anchor prop for popovers. * Reference migrated alerting plugin. * Clean up code for draft. * Add button to expose flyout. Clean up some client code. * Add test for requests function, add support for filters. * Reorganize and clean up files. * Add location and filter support to monitor status request function. * Add tests for monitor status request function. * Specify default action group id in alert registration. * Extract repeated string value to a constant. * Move test file to server in NP plugin. * Update imports after NP migration. * Fix UI bug that caused incorrect location selections in alert creation. * Change alert expression language to clarify meaning. * Add ability for user to select timerange units. * Add code that fixes active item highlighting. * Add better default value for active index selection. * Introduce dedicated field number component. * Add message to status check alert. * Add tests for context message. * Formalize alert action group definitions. * Extract monitor id squashing from context message generator. * Write test for monitor ID uniqueness function. * Add alert state creator function and tests. * Update action group id value. * Add tests for alert factory and executor function. * Rename alert context props to be more domain-specific. * Clean up unnecessary type markup. * Clean up alert ui controls file. * Better organize new registration code. * Simplify some logic code. * Clean up bootstrap code. * Add unit tests for alert type. * Delete temporary test code from triggers_actions_ui. * Rename a test file. * Add some comments to annotate a file. * Add io-ts type checking to alert create validation and alert executor. * Add translation of plaintext content string. * Further simplify monitor status alert validation. * Add io-ts type checking to alert params. * Update a comment. * Prefer inline snapshots to more error-prone assertions. * Clean up and comment request function. * Rename a symbol. * Fix broken types in reducer file and add a test. * Fix a validation logic error and add tests. * Delete unused import. * Delete obsolete dependency. * Fix function call to have correct parameters. * Fixing some import weirdness. * Reintroduce accidentally-deleted code. * Delete unneeded require from legacy entry file. * Remove unneeded connected component. * Update flyout controls for new interface and delete connected components. * Remove unneeded require from app index file. * Introduce data-test-subj attributes to various components to assist with functional tests. * Introduce functional test helpers for alert flyout. * Add functional test arch and a test for alerting UI to ES SSL test suite. * Add explicit exports to module index. * Reorganize file to keep interfaces closer to their implementations. * Move create alert button to better position. * Clean up a file. * Update a functional test attribute, clean up a file, rename a selector, add tests. * Add a comment. * Make better default alert message, translate messages, add/update tests. * Fix broken type. * Update obsolete snapshot. * Introduce mock provider to tests and update snapshots. * Reduce a strange type to `any`. * Add alert flyout button connected component. * Add alert flyout wrapper connected component. * Create connected component for alert monitor status alert. * Clean up index files. * Update i18nrc file to cover translation in server plugin code. * Fix broken imports. * Update test snapshots. * Prefer more descriptive type. * Prefer more descriptive type. * Prefer built-in React propType to custom. * Prefer simpler validation. * Add whitespace to clean up file. * Extract function and write tests. * Simplify validation function. * Add navigate to alerting button. * Move context item inside the items list. * Clean up alert creation component. * Update type check parsing and error messaging, and update snapshot/test assertions. * Update broken snapshot. * Update README for running functional tests. * Update functional test service to reflect improved UX. * Fix broken type that resulted from a mistake during a merge resolution. * Add spacer between alert title and kuery bar. * Update the id and name of our alert type because it was never changed from placeholder value. * Rename alert keys. * Fix broken unit tests. * Add aria-labels to alert UI. * Implement design feedback. * Fix broken test snapshots. * Add missing props to unit tests to staisfy updated types. Co-authored-by: Elastic Machine --- x-pack/.i18nrc.json | 2 +- x-pack/legacy/plugins/uptime/README.md | 10 + .../plugins/uptime/common/constants/alerts.ts | 19 + .../plugins/uptime/common/constants/index.ts | 1 + .../uptime/common/constants/index_names.ts | 1 - .../common/runtime_types/alerts/index.ts | 12 + .../runtime_types/alerts/status_check.ts | 39 ++ .../uptime/common/runtime_types/index.ts | 1 + x-pack/legacy/plugins/uptime/index.ts | 2 +- .../plugins/uptime/public/apps/index.ts | 5 +- .../plugins/uptime/public/apps/plugin.ts | 2 + .../connected/alerts/alert_monitor_status.tsx | 43 ++ .../components/connected/alerts/index.ts | 9 + .../alerts/toggle_alert_flyout_button.tsx | 19 + .../alerts/uptime_alerts_flyout_wrapper.tsx | 34 + .../public/components/connected/index.ts | 1 + .../kuerybar/kuery_bar_container.tsx | 2 +- .../__tests__/alert_monitor_status.test.tsx | 179 ++++++ .../alerts/alert_monitor_status.tsx | 431 +++++++++++++ .../components/functional/alerts/index.ts | 10 + .../alerts/toggle_alert_flyout_button.tsx | 79 +++ .../alerts/uptime_alerts_context_provider.tsx | 38 ++ .../alerts/uptime_alerts_flyout_wrapper.tsx | 30 + .../public/components/functional/index.ts | 6 + .../functional/kuery_bar/kuery_bar.tsx | 6 + .../functional/kuery_bar/typeahead/index.js | 3 +- .../ping_list/__tests__/ping_list.test.tsx | 3 +- .../framework/new_platform_adapter.tsx | 16 + .../__tests__/monitor_status.test.ts | 181 ++++++ .../uptime/public/lib/alert_types/index.ts | 14 + .../public/lib/alert_types/monitor_status.tsx | 71 +++ .../__snapshots__/page_header.test.tsx.snap | 66 ++ .../pages/__tests__/page_header.test.tsx | 54 +- .../plugins/uptime/public/pages/overview.tsx | 8 +- .../uptime/public/pages/page_header.tsx | 4 + .../plugins/uptime/public/state/actions/ui.ts | 2 + .../__tests__/__snapshots__/ui.test.ts.snap | 3 + .../state/reducers/__tests__/ui.test.ts | 34 +- .../uptime/public/state/reducers/ui.ts | 12 +- .../state/selectors/__tests__/index.test.ts | 1 + .../uptime/public/state/selectors/index.ts | 9 + .../plugins/uptime/public/uptime_app.tsx | 15 +- x-pack/plugins/uptime/kibana.json | 2 +- x-pack/plugins/uptime/server/kibana.index.ts | 2 +- .../lib/adapters/framework/adapter_types.ts | 2 + .../lib/alerts/__tests__/status_check.test.ts | 587 ++++++++++++++++++ .../plugins/uptime/server/lib/alerts/index.ts | 10 + .../uptime/server/lib/alerts/status_check.ts | 234 +++++++ .../plugins/uptime/server/lib/alerts/types.ts | 11 + .../__tests__/get_monitor_status.test.ts | 553 +++++++++++++++++ .../server/lib/requests/get_monitor_status.ts | 150 +++++ .../uptime/server/lib/requests/index.ts | 2 + .../server/lib/requests/uptime_requests.ts | 3 + x-pack/plugins/uptime/server/uptime_server.ts | 12 +- .../functional/page_objects/uptime_page.ts | 40 +- x-pack/test/functional/services/uptime.ts | 94 ++- .../apps/uptime/alert_flyout.ts | 78 +++ .../apps/uptime/index.ts | 27 + x-pack/test/functional_with_es_ssl/config.ts | 5 +- 59 files changed, 3245 insertions(+), 44 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/common/constants/alerts.ts create mode 100644 x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts create mode 100644 x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts create mode 100644 x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts create mode 100644 x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx create mode 100644 x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/index.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/status_check.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/types.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/uptime/index.ts diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 1564eb94a6903..d568e9b951d28 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -42,7 +42,7 @@ "xpack.transform": "plugins/transform", "xpack.triggersActionsUI": "plugins/triggers_actions_ui", "xpack.upgradeAssistant": "plugins/upgrade_assistant", - "xpack.uptime": "legacy/plugins/uptime", + "xpack.uptime": ["plugins/uptime", "legacy/plugins/uptime"], "xpack.watcher": "plugins/watcher" }, "translations": [ diff --git a/x-pack/legacy/plugins/uptime/README.md b/x-pack/legacy/plugins/uptime/README.md index 308f78ecdc368..2ed0e2fc77cbc 100644 --- a/x-pack/legacy/plugins/uptime/README.md +++ b/x-pack/legacy/plugins/uptime/README.md @@ -62,3 +62,13 @@ You can login with username `elastic` and password `changeme` by default. If you want to freeze a UI or API test you can include an async call like `await new Promise(r => setTimeout(r, 1000 * 60))` to freeze the execution for 60 seconds if you need to click around or check things in the state that is loaded. + +#### Running --ssl tests + +Some of our tests require there to be an SSL connection between Kibana and Elasticsearch. + +We can run these tests like described above, but with some special config. + +`node scripts/functional_tests_server.js --config=test/functional_with_es_ssl/config.ts` + +`node scripts/functional_test_runner.js --config=test/functional_with_es_ssl/config.ts` diff --git a/x-pack/legacy/plugins/uptime/common/constants/alerts.ts b/x-pack/legacy/plugins/uptime/common/constants/alerts.ts new file mode 100644 index 0000000000000..c0db9ae309843 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/constants/alerts.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface ActionGroupDefinition { + id: string; + name: string; +} + +type ActionGroupDefinitions = Record; + +export const ACTION_GROUP_DEFINITIONS: ActionGroupDefinitions = { + MONITOR_STATUS: { + id: 'xpack.uptime.alerts.actionGroups.monitorStatus', + name: 'Uptime Down Monitor', + }, +}; diff --git a/x-pack/legacy/plugins/uptime/common/constants/index.ts b/x-pack/legacy/plugins/uptime/common/constants/index.ts index 0425fc19a7b45..19f2de3c6f0f4 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/index.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { ACTION_GROUP_DEFINITIONS } from './alerts'; export { CHART_FORMAT_LIMITS } from './chart_format_limits'; export { CLIENT_DEFAULTS } from './client_defaults'; export { CONTEXT_DEFAULTS } from './context_defaults'; diff --git a/x-pack/legacy/plugins/uptime/common/constants/index_names.ts b/x-pack/legacy/plugins/uptime/common/constants/index_names.ts index e9c6b1e1106ab..9f33d280a1268 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/index_names.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/index_names.ts @@ -6,5 +6,4 @@ export const INDEX_NAMES = { HEARTBEAT: 'heartbeat-8*', - HEARTBEAT_STATES: 'heartbeat-states-8*', }; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts new file mode 100644 index 0000000000000..ee284249c38c0 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + StatusCheckAlertStateType, + StatusCheckAlertState, + StatusCheckExecutorParamsType, + StatusCheckExecutorParams, +} from './status_check'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts new file mode 100644 index 0000000000000..bc234b268df27 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +export const StatusCheckAlertStateType = t.intersection([ + t.partial({ + currentTriggerStarted: t.string, + firstTriggeredAt: t.string, + lastTriggeredAt: t.string, + lastResolvedAt: t.string, + }), + t.type({ + firstCheckedAt: t.string, + lastCheckedAt: t.string, + isTriggered: t.boolean, + }), +]); + +export type StatusCheckAlertState = t.TypeOf; + +export const StatusCheckExecutorParamsType = t.intersection([ + t.partial({ + filters: t.string, + }), + t.type({ + locations: t.array(t.string), + numTimes: t.number, + timerange: t.type({ + from: t.string, + to: t.string, + }), + }), +]); + +export type StatusCheckExecutorParams = t.TypeOf; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts index 58f79abcf91ec..82fc9807300ed 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './alerts'; export * from './common'; export * from './monitor'; export * from './overview_filters'; diff --git a/x-pack/legacy/plugins/uptime/index.ts b/x-pack/legacy/plugins/uptime/index.ts index feecef5857895..f52ad8ce867b6 100644 --- a/x-pack/legacy/plugins/uptime/index.ts +++ b/x-pack/legacy/plugins/uptime/index.ts @@ -14,7 +14,7 @@ export const uptime = (kibana: any) => configPrefix: 'xpack.uptime', id: PLUGIN.ID, publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch', 'xpack_main'], + require: ['alerting', 'kibana', 'elasticsearch', 'xpack_main'], uiExports: { app: { description: i18n.translate('xpack.uptime.pluginDescription', { diff --git a/x-pack/legacy/plugins/uptime/public/apps/index.ts b/x-pack/legacy/plugins/uptime/public/apps/index.ts index d322c35364d1a..d58bf8398fcde 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/index.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/index.ts @@ -8,8 +8,9 @@ import { npSetup } from 'ui/new_platform'; import { Plugin } from './plugin'; import 'uiExports/embeddableFactories'; -new Plugin({ +const plugin = new Plugin({ opaqueId: Symbol('uptime'), env: {} as any, config: { get: () => ({} as any) }, -}).setup(npSetup); +}); +plugin.setup(npSetup); diff --git a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts index 2204d7e4097dd..eec49418910f8 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts @@ -36,6 +36,7 @@ export class Plugin { public setup(setup: SetupObject) { const { core, plugins } = setup; const { home } = plugins; + home.featureCatalogue.register({ category: FeatureCatalogueCategory.DATA, description: PLUGIN.DESCRIPTION, @@ -45,6 +46,7 @@ export class Plugin { showOnHomePage: true, title: PLUGIN.TITLE, }); + core.application.register({ id: PLUGIN.ID, euiIconType: 'uptimeApp', diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx new file mode 100644 index 0000000000000..1529ab6db8875 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useSelector } from 'react-redux'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { selectMonitorStatusAlert } from '../../../state/selectors'; +import { AlertMonitorStatusComponent } from '../../functional/alerts/alert_monitor_status'; + +interface Props { + autocomplete: DataPublicPluginSetup['autocomplete']; + enabled: boolean; + numTimes: number; + setAlertParams: (key: string, value: any) => void; + timerange: { + from: string; + to: string; + }; +} + +export const AlertMonitorStatus = ({ + autocomplete, + enabled, + numTimes, + setAlertParams, + timerange, +}: Props) => { + const { filters, locations } = useSelector(selectMonitorStatusAlert); + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts new file mode 100644 index 0000000000000..87179a96fc0b2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AlertMonitorStatus } from './alert_monitor_status'; +export { ToggleAlertFlyoutButton } from './toggle_alert_flyout_button'; +export { UptimeAlertsFlyoutWrapper } from './uptime_alerts_flyout_wrapper'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx new file mode 100644 index 0000000000000..43b0be45365a1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { ToggleAlertFlyoutButtonComponent } from '../../functional'; +import { setAlertFlyoutVisible } from '../../../state/actions'; + +export const ToggleAlertFlyoutButton = () => { + const dispatch = useDispatch(); + return ( + dispatch(setAlertFlyoutVisible(value))} + /> + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx new file mode 100644 index 0000000000000..b547f8b076f93 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { UptimeAlertsFlyoutWrapperComponent } from '../../functional'; +import { setAlertFlyoutVisible } from '../../../state/actions'; +import { selectAlertFlyoutVisibility } from '../../../state/selectors'; + +interface Props { + alertTypeId?: string; + canChangeTrigger?: boolean; +} + +export const UptimeAlertsFlyoutWrapper = ({ alertTypeId, canChangeTrigger }: Props) => { + const dispatch = useDispatch(); + const setAddFlyoutVisiblity = (value: React.SetStateAction) => + // @ts-ignore the value here is a boolean, and it works with the action creator function + dispatch(setAlertFlyoutVisible(value)); + + const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts index baa961ddc87d2..7e442cbe850ba 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { AlertMonitorStatus, ToggleAlertFlyoutButton, UptimeAlertsFlyoutWrapper } from './alerts'; export { PingHistogram } from './charts/ping_histogram'; export { Snapshot } from './charts/snapshot_container'; export { KueryBar } from './kuerybar/kuery_bar_container'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx index a42f96962b95e..132ae57b5154f 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { AppState } from '../../../state'; import { selectIndexPattern } from '../../../state/selectors'; import { getIndexPattern } from '../../../state/actions'; -import { KueryBarComponent } from '../../functional'; +import { KueryBarComponent } from '../../functional/kuery_bar/kuery_bar'; const mapStateToProps = (state: AppState) => ({ ...selectIndexPattern(state) }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx new file mode 100644 index 0000000000000..af8d17d1fc242 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + selectedLocationsToString, + AlertFieldNumber, + handleAlertFieldNumberChange, +} from '../alert_monitor_status'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +describe('alert monitor status component', () => { + describe('handleAlertFieldNumberChange', () => { + let mockSetIsInvalid: jest.Mock; + let mockSetFieldValue: jest.Mock; + + beforeEach(() => { + mockSetIsInvalid = jest.fn(); + mockSetFieldValue = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets a valid number', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: '23' } }, + false, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).not.toHaveBeenCalled(); + expect(mockSetFieldValue).toHaveBeenCalledTimes(1); + expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 23, + ], + ] + `); + }); + + it('sets invalid for NaN value', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: 'foo' } }, + false, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).toHaveBeenCalledTimes(1); + expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + true, + ], + ] + `); + expect(mockSetFieldValue).not.toHaveBeenCalled(); + }); + + it('sets invalid to false when a valid value is received and invalid is true', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: '23' } }, + true, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).toHaveBeenCalledTimes(1); + expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + false, + ], + ] + `); + expect(mockSetFieldValue).toHaveBeenCalledTimes(1); + expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 23, + ], + ] + `); + }); + }); + + describe('AlertFieldNumber', () => { + it('responds with correct number value when a valid number is specified', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: '45' } }); + expect(mockValueHandler).toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 45, + ], + ] + `); + }); + + it('does not set an invalid number value', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: 'not a number' } }); + expect(mockValueHandler).not.toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toEqual([]); + }); + + it('does not set a number value less than 1', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: '0' } }); + expect(mockValueHandler).not.toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toEqual([]); + }); + }); + + describe('selectedLocationsToString', () => { + it('generates a formatted string for a valid list of options', () => { + const locations = [ + { + checked: 'on', + label: 'fairbanks', + }, + { + checked: 'on', + label: 'harrisburg', + }, + { + checked: undefined, + label: 'orlando', + }, + ]; + expect(selectedLocationsToString(locations)).toEqual('fairbanks, harrisburg'); + }); + + it('generates a formatted string for a single item', () => { + expect(selectedLocationsToString([{ checked: 'on', label: 'fairbanks' }])).toEqual( + 'fairbanks' + ); + }); + + it('returns an empty string when no valid options are available', () => { + expect(selectedLocationsToString([{ checked: 'off', label: 'harrisburg' }])).toEqual(''); + }); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx new file mode 100644 index 0000000000000..5143e1c963904 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx @@ -0,0 +1,431 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiExpression, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiSelectable, + EuiSpacer, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { KueryBar } from '../../connected/kuerybar/kuery_bar_container'; + +interface AlertFieldNumberProps { + 'aria-label': string; + 'data-test-subj': string; + disabled: boolean; + fieldValue: number; + setFieldValue: React.Dispatch>; +} + +export const handleAlertFieldNumberChange = ( + e: React.ChangeEvent, + isInvalid: boolean, + setIsInvalid: React.Dispatch>, + setFieldValue: React.Dispatch> +) => { + const num = parseInt(e.target.value, 10); + if (isNaN(num) || num < 1) { + setIsInvalid(true); + } else { + if (isInvalid) setIsInvalid(false); + setFieldValue(num); + } +}; + +export const AlertFieldNumber = ({ + 'aria-label': ariaLabel, + 'data-test-subj': dataTestSubj, + disabled, + fieldValue, + setFieldValue, +}: AlertFieldNumberProps) => { + const [isInvalid, setIsInvalid] = useState(false); + + return ( + handleAlertFieldNumberChange(e, isInvalid, setIsInvalid, setFieldValue)} + disabled={disabled} + value={fieldValue} + isInvalid={isInvalid} + /> + ); +}; + +interface AlertExpressionPopoverProps { + 'aria-label': string; + content: React.ReactElement; + description: string; + 'data-test-subj': string; + id: string; + value: string; +} + +const AlertExpressionPopover: React.FC = ({ + 'aria-label': ariaLabel, + content, + 'data-test-subj': dataTestSubj, + description, + id, + value, +}) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(!isOpen)} + value={value} + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + {content} + + ); +}; + +export const selectedLocationsToString = (selectedLocations: any[]) => + // create a nicely-formatted description string for all `on` locations + selectedLocations + .filter(({ checked }) => checked === 'on') + .map(({ label }) => label) + .sort() + .reduce((acc, cur) => { + if (acc === '') { + return cur; + } + return acc + `, ${cur}`; + }, ''); + +interface AlertMonitorStatusProps { + autocomplete: DataPublicPluginSetup['autocomplete']; + enabled: boolean; + filters: string; + locations: string[]; + numTimes: number; + setAlertParams: (key: string, value: any) => void; + timerange: { + from: string; + to: string; + }; +} + +export const AlertMonitorStatusComponent: React.FC = props => { + const { filters, locations } = props; + const [numTimes, setNumTimes] = useState(5); + const [numMins, setNumMins] = useState(15); + const [allLabels, setAllLabels] = useState(true); + + // locations is an array of `Option[]`, but that type doesn't seem to be exported by EUI + const [selectedLocations, setSelectedLocations] = useState( + locations.map(location => ({ + 'aria-label': i18n.translate('xpack.uptime.alerts.locationSelectionItem.ariaLabel', { + defaultMessage: 'Location selection item for "{location}"', + values: { + location, + }, + }), + disabled: allLabels, + label: location, + })) + ); + const [timerangeUnitOptions, setTimerangeUnitOptions] = useState([ + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.secondsOption.ariaLabel', + { + defaultMessage: '"Seconds" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.secondsOption', + key: 's', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.seconds', { + defaultMessage: 'seconds', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.minutesOption.ariaLabel', + { + defaultMessage: '"Minutes" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.minutesOption', + checked: 'on', + key: 'm', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.minutes', { + defaultMessage: 'minutes', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.hoursOption.ariaLabel', + { + defaultMessage: '"Hours" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption', + key: 'h', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.hours', { + defaultMessage: 'hours', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.daysOption.ariaLabel', + { + defaultMessage: '"Days" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.daysOption', + key: 'd', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.days', { + defaultMessage: 'days', + }), + }, + ]); + + const { setAlertParams } = props; + + useEffect(() => { + setAlertParams('numTimes', numTimes); + }, [numTimes, setAlertParams]); + + useEffect(() => { + const timerangeUnit = timerangeUnitOptions.find(({ checked }) => checked === 'on')?.key ?? 'm'; + setAlertParams('timerange', { from: `now-${numMins}${timerangeUnit}`, to: 'now' }); + }, [numMins, timerangeUnitOptions, setAlertParams]); + + useEffect(() => { + if (allLabels) { + setAlertParams('locations', []); + } else { + setAlertParams( + 'locations', + selectedLocations.filter(l => l.checked === 'on').map(l => l.label) + ); + } + }, [selectedLocations, setAlertParams, allLabels]); + + useEffect(() => { + setAlertParams('filters', filters); + }, [filters, setAlertParams]); + + return ( + <> + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.numTimesExpression" + description="any monitor is down >" + id="ping-count" + value={`${numTimes} times`} + /> + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeValueExpression" + description="within" + id="timerange" + value={`last ${numMins}`} + /> + + + + +
      + +
      +
      + { + if (newOptions.reduce((acc, { checked }) => acc || checked === 'on', false)) { + setTimerangeUnitOptions(newOptions); + } + }} + singleSelection={true} + listProps={{ + showIcons: true, + }} + > + {list => list} + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeUnitExpression" + description="" + id="timerange-unit" + value={ + timerangeUnitOptions.find(({ checked }) => checked === 'on')?.label.toLowerCase() ?? + '' + } + /> +
      +
      + + {selectedLocations.length === 0 && ( + + )} + {selectedLocations.length > 0 && ( + + + { + setAllLabels(!allLabels); + setSelectedLocations( + selectedLocations.map((l: any) => ({ + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.monitorStatus.locationSelection', + { + defaultMessage: 'Select the location {location}', + values: { + location: l, + }, + } + ), + ...l, + 'data-test-subj': `xpack.uptime.alerts.monitorStatus.locationSelection.${l.label}LocationOption`, + disabled: !allLabels, + })) + ); + }} + /> + + + setSelectedLocations(e)} + > + {location => location} + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.locationsSelectionExpression" + description="from" + id="locations" + value={ + selectedLocations.length === 0 || allLabels + ? 'any location' + : selectedLocationsToString(selectedLocations) + } + /> + )} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts new file mode 100644 index 0000000000000..275333b60c5ee --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AlertMonitorStatusComponent } from './alert_monitor_status'; +export { ToggleAlertFlyoutButtonComponent } from './toggle_alert_flyout_button'; +export { UptimeAlertsContextProvider } from './uptime_alerts_context_provider'; +export { UptimeAlertsFlyoutWrapperComponent } from './uptime_alerts_flyout_wrapper'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx new file mode 100644 index 0000000000000..99853a9f775ec --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; + +interface Props { + setAlertFlyoutVisible: (value: boolean) => void; +} + +export const ToggleAlertFlyoutButtonComponent = ({ setAlertFlyoutVisible }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const kibana = useKibana(); + + return ( + setIsOpen(!isOpen)} + > + + + } + closePopover={() => setIsOpen(false)} + isOpen={isOpen} + ownFocus + > + setAlertFlyoutVisible(true)} + > + + , + + + , + ]} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx new file mode 100644 index 0000000000000..a174a7d9c0ea4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { AlertsContextProvider } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; + +export const UptimeAlertsContextProvider: React.FC = ({ children }) => { + const { + services: { + data: { fieldFormats }, + http, + charts, + notifications, + triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry }, + uiSettings, + }, + } = useKibana(); + + return ( + + {children} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx new file mode 100644 index 0000000000000..13705e7d19293 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { AlertAdd } from '../../../../../../../plugins/triggers_actions_ui/public'; + +interface Props { + alertFlyoutVisible: boolean; + alertTypeId?: string; + canChangeTrigger?: boolean; + setAlertFlyoutVisibility: React.Dispatch>; +} + +export const UptimeAlertsFlyoutWrapperComponent = ({ + alertFlyoutVisible, + alertTypeId, + canChangeTrigger, + setAlertFlyoutVisibility, +}: Props) => ( + +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts index daba13d8df641..8d0352e01d40e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts @@ -4,6 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +export { + ToggleAlertFlyoutButtonComponent, + UptimeAlertsContextProvider, + UptimeAlertsFlyoutWrapperComponent, +} from './alerts'; +export * from './alerts'; export { DonutChart } from './charts/donut_chart'; export { KueryBarComponent } from './kuery_bar/kuery_bar'; export { MonitorCharts } from './monitor_charts'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx index 2f5ccc2adf313..63aceed2be636 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx @@ -33,14 +33,18 @@ function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) { } interface Props { + 'aria-label': string; autocomplete: DataPublicPluginSetup['autocomplete']; + 'data-test-subj': string; loadIndexPattern: () => void; indexPattern: IIndexPattern | null; loading: boolean; } export function KueryBarComponent({ + 'aria-label': ariaLabel, autocomplete: autocompleteService, + 'data-test-subj': dataTestSubj, loadIndexPattern, indexPattern, loading, @@ -119,6 +123,8 @@ export function KueryBarComponent({ return ( -
      +
      { @@ -205,7 +204,7 @@ describe('PingList component', () => { loading={false} data={{ allPings }} onPageCountChange={jest.fn()} - onSelectedLocationChange={(loc: EuiComboBoxOptionOption[]) => {}} + onSelectedLocationChange={(_loc: any[]) => {}} onSelectedStatusChange={jest.fn()} pageSize={30} selectedOption="down" diff --git a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx index a377b9ed1507b..a2f3328b98612 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx +++ b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx @@ -10,6 +10,7 @@ import ReactDOM from 'react-dom'; import { get } from 'lodash'; import { i18n as i18nFormatter } from '@kbn/i18n'; import { PluginsSetup } from 'ui/new_platform/new_platform'; +import { alertTypeInitializers } from '../../alert_types'; import { UptimeApp, UptimeAppProps } from '../../../uptime_app'; import { getIntegratedAppAvailability } from './capabilities_adapter'; import { @@ -32,15 +33,30 @@ export const getKibanaFrameworkAdapter = ( http: { basePath }, i18n, } = core; + + const { + data: { autocomplete }, + // TODO: after NP migration we can likely fix this typing problem + // @ts-ignore we don't control this type + triggers_actions_ui, + } = plugins; + + alertTypeInitializers.forEach(init => + triggers_actions_ui.alertTypeRegistry.register(init({ autocomplete })) + ); + let breadcrumbs: ChromeBreadcrumb[] = []; core.chrome.getBreadcrumbs$().subscribe((nextBreadcrumbs?: ChromeBreadcrumb[]) => { breadcrumbs = nextBreadcrumbs || []; }); + const { apm, infrastructure, logs } = getIntegratedAppAvailability( capabilities, INTEGRATED_SOLUTIONS ); + const canSave = get(capabilities, 'uptime.save', false); + const props: UptimeAppProps = { basePath: basePath.get(), canSave, diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts new file mode 100644 index 0000000000000..6323ee3951e21 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validate, initMonitorStatusAlertType } from '../monitor_status'; + +describe('monitor status alert type', () => { + describe('validate', () => { + let params: any; + + beforeEach(() => { + params = { + locations: [], + numTimes: 5, + timerange: { + from: 'now-15m', + to: 'now', + }, + }; + }); + + it(`doesn't throw on empty set`, () => { + expect(validate({})).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/locations: Array", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }", + ], + }, + } + `); + }); + + describe('timerange', () => { + it('is undefined', () => { + delete params.timerange; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }", + ], + }, + } + `); + }); + + it('is missing `from` or `to` value', () => { + expect( + validate({ + ...params, + timerange: {}, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/from: string", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/to: string", + ], + }, + } + `); + }); + + it('is invalid timespan', () => { + expect( + validate({ + ...params, + timerange: { + from: 'now', + to: 'now-15m', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "invalidTimeRange": "Time range start cannot exceed time range end", + }, + } + `); + }); + + it('has unparse-able `from` value', () => { + expect( + validate({ + ...params, + timerange: { + from: 'cannot parse this to a date', + to: 'now', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "timeRangeStartValueNaN": "Specified time range \`from\` is an invalid value", + }, + } + `); + }); + + it('has unparse-able `to` value', () => { + expect( + validate({ + ...params, + timerange: { + from: 'now-15m', + to: 'cannot parse this to a date', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "timeRangeEndValueNaN": "Specified time range \`to\` is an invalid value", + }, + } + `); + }); + }); + + describe('numTimes', () => { + it('is missing', () => { + delete params.numTimes; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + ], + }, + } + `); + }); + + it('is NaN', () => { + expect(validate({ ...params, numTimes: `this isn't a number` })).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value \\"this isn't a number\\" supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + ], + }, + } + `); + }); + + it('is < 1', () => { + expect(validate({ ...params, numTimes: 0 })).toMatchInlineSnapshot(` + Object { + "errors": Object { + "invalidNumTimes": "Number of alert check down times must be an integer greater than 0", + }, + } + `); + }); + }); + }); + + describe('initMonitorStatusAlertType', () => { + expect(initMonitorStatusAlertType({ autocomplete: {} })).toMatchInlineSnapshot(` + Object { + "alertParamsExpression": [Function], + "defaultActionMessage": "{{context.message}} + {{context.completeIdList}}", + "iconClass": "uptimeApp", + "id": "xpack.uptime.alerts.monitorStatus", + "name": "Uptime Monitor Status", + "validate": [Function], + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts new file mode 100644 index 0000000000000..f764505a6d683 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// TODO: after NP migration is complete we should be able to remove this lint ignore comment +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../../../plugins/triggers_actions_ui/public/types'; +import { initMonitorStatusAlertType } from './monitor_status'; + +export type AlertTypeInitializer = (dependenies: { autocomplete: any }) => AlertTypeModel; + +export const alertTypeInitializers: AlertTypeInitializer[] = [initMonitorStatusAlertType]; diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx new file mode 100644 index 0000000000000..effbb59539d16 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PathReporter } from 'io-ts/lib/PathReporter'; +import React from 'react'; +import DateMath from '@elastic/datemath'; +import { isRight } from 'fp-ts/lib/Either'; +import { + AlertTypeModel, + ValidationResult, + // TODO: this typing issue should be resolved after NP migration + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../plugins/triggers_actions_ui/public/types'; +import { AlertTypeInitializer } from '.'; +import { StatusCheckExecutorParamsType } from '../../../common/runtime_types'; +import { AlertMonitorStatus } from '../../components/connected/alerts'; + +export const validate = (alertParams: any): ValidationResult => { + const errors: Record = {}; + const decoded = StatusCheckExecutorParamsType.decode(alertParams); + + /* + * When the UI initially loads, this validate function is called with an + * empty set of params, we don't want to type check against that. + */ + if (!isRight(decoded)) { + errors.typeCheckFailure = 'Provided parameters do not conform to the expected type.'; + errors.typeCheckParsingMessage = PathReporter.report(decoded); + } + + if (isRight(decoded)) { + const { numTimes, timerange } = decoded.right; + const { from, to } = timerange; + const fromAbs = DateMath.parse(from)?.valueOf(); + const toAbs = DateMath.parse(to)?.valueOf(); + if (!fromAbs || isNaN(fromAbs)) { + errors.timeRangeStartValueNaN = 'Specified time range `from` is an invalid value'; + } + if (!toAbs || isNaN(toAbs)) { + errors.timeRangeEndValueNaN = 'Specified time range `to` is an invalid value'; + } + + // the default values for this test will pass, we only want to specify an error + // in the case that `from` is more recent than `to` + if ((fromAbs ?? 0) > (toAbs ?? 1)) { + errors.invalidTimeRange = 'Time range start cannot exceed time range end'; + } + + if (numTimes < 1) { + errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0'; + } + } + + return { errors }; +}; + +export const initMonitorStatusAlertType: AlertTypeInitializer = ({ + autocomplete, +}): AlertTypeModel => ({ + id: 'xpack.uptime.alerts.monitorStatus', + name: 'Uptime Monitor Status', + iconClass: 'uptimeApp', + alertParamsExpression: params => { + return ; + }, + validate, + defaultActionMessage: '{{context.message}}\n{{context.completeIdList}}', +}); diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index 5906a77f55441..30e15ba132996 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -14,6 +14,39 @@ Array [ TestingHeading
      +
      +
      +
      + +
      +
      +
      @@ -130,6 +163,39 @@ Array [ TestingHeading
      +
      +
      +
      + +
      +
      +
      ,
      { const simpleBreadcrumbs: ChromeBreadcrumb[] = [ @@ -21,22 +22,26 @@ describe('PageHeader', () => { it('shallow renders with breadcrumbs and the date picker', () => { const component = renderWithRouter( - + + + ); expect(component).toMatchSnapshot('page_header_with_date_picker'); }); it('shallow renders with breadcrumbs without the date picker', () => { const component = renderWithRouter( - + + + ); expect(component).toMatchSnapshot('page_header_no_date_picker'); }); @@ -45,13 +50,15 @@ describe('PageHeader', () => { const [getBreadcrumbs, core] = mockCore(); mountWithRouter( - - - + + + + + ); @@ -62,6 +69,19 @@ describe('PageHeader', () => { }); }); +const MockReduxProvider = ({ children }: { children: React.ReactElement }) => ( + + {children} + +); + const mockCore: () => [() => ChromeBreadcrumb[], any] = () => { let breadcrumbObj: ChromeBreadcrumb[] = []; const get = () => { diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index af9b8bf046416..f9184e2a0587f 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -83,7 +83,13 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi - + diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx index b0fb2d0ed7869..56d9ae2d5caa6 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx @@ -13,6 +13,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useUrlParams } from '../hooks'; import { UptimeUrlParams } from '../lib/helper'; +import { ToggleAlertFlyoutButton } from '../components/connected'; interface PageHeaderProps { headingText: string; @@ -60,6 +61,9 @@ export const PageHeader = ({ headingText, breadcrumbs, datePicker = true }: Page

      {headingText}

      + + + {datePickerComponent}
      diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts index d15d601737b2d..4885f974dbbd4 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts @@ -12,6 +12,8 @@ export interface PopoverState { export type UiPayload = PopoverState & string & number & Map; +export const setAlertFlyoutVisible = createAction('TOGGLE ALERT FLYOUT'); + export const setBasePath = createAction('SET BASE PATH'); export const triggerAppRefresh = createAction('REFRESH APP'); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index 5d03c0058c3c1..1dc4e45606c60 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -2,6 +2,7 @@ exports[`ui reducer adds integration popover status to state 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "", "esKuery": "", "integrationsPopoverOpen": Object { @@ -14,6 +15,7 @@ Object { exports[`ui reducer sets the application's base path 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "yyz", "esKuery": "", "integrationsPopoverOpen": null, @@ -23,6 +25,7 @@ Object { exports[`ui reducer updates the refresh value 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "abc", "esKuery": "", "integrationsPopoverOpen": null, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index 417095b64ba2d..3c134366347aa 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { setBasePath, toggleIntegrationsPopover, triggerAppRefresh } from '../../actions'; +import { + setBasePath, + toggleIntegrationsPopover, + triggerAppRefresh, + setAlertFlyoutVisible, +} from '../../actions'; import { uiReducer } from '../ui'; import { Action } from 'redux-actions'; @@ -14,6 +19,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: 'abc', esKuery: '', integrationsPopoverOpen: null, @@ -32,6 +38,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: '', esKuery: '', integrationsPopoverOpen: null, @@ -47,6 +54,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: 'abc', esKuery: '', integrationsPopoverOpen: null, @@ -56,4 +64,28 @@ describe('ui reducer', () => { ) ).toMatchSnapshot(); }); + + it('updates the alert flyout value', () => { + const action = setAlertFlyoutVisible(true) as Action; + expect( + uiReducer( + { + alertFlyoutVisible: false, + basePath: '', + esKuery: '', + integrationsPopoverOpen: null, + lastRefresh: 125, + }, + action + ) + ).toMatchInlineSnapshot(` + Object { + "alertFlyoutVisible": true, + "basePath": "", + "esKuery": "", + "integrationsPopoverOpen": null, + "lastRefresh": 125, + } + `); + }); }); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts index bb5bd22085ac6..702d314250521 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts @@ -12,19 +12,22 @@ import { setEsKueryString, triggerAppRefresh, UiPayload, + setAlertFlyoutVisible, } from '../actions/ui'; export interface UiState { - integrationsPopoverOpen: PopoverState | null; + alertFlyoutVisible: boolean; basePath: string; esKuery: string; + integrationsPopoverOpen: PopoverState | null; lastRefresh: number; } const initialState: UiState = { - integrationsPopoverOpen: null, + alertFlyoutVisible: false, basePath: '', esKuery: '', + integrationsPopoverOpen: null, lastRefresh: Date.now(), }; @@ -35,6 +38,11 @@ export const uiReducer = handleActions( integrationsPopoverOpen: action.payload as PopoverState, }), + [String(setAlertFlyoutVisible)]: (state, action: Action) => ({ + ...state, + alertFlyoutVisible: action.payload ?? !state.alertFlyoutVisible, + }), + [String(setBasePath)]: (state, action: Action) => ({ ...state, basePath: action.payload as string, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index de446418632b8..b1da995709f93 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -35,6 +35,7 @@ describe('state selectors', () => { loading: false, }, ui: { + alertFlyoutVisible: false, basePath: 'yyz', esKuery: '', integrationsPopoverOpen: null, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts index 4767c25e8f52f..7b5a5ddf8d3ca 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts @@ -46,6 +46,15 @@ export const selectDurationLines = ({ monitorDuration }: AppState) => { return monitorDuration; }; +export const selectAlertFlyoutVisibility = ({ ui: { alertFlyoutVisible } }: AppState) => + alertFlyoutVisible; + +export const selectMonitorStatusAlert = ({ indexPattern, overviewFilters, ui }: AppState) => ({ + filters: ui.esKuery, + indexPattern: indexPattern.index_pattern, + locations: overviewFilters.filters.locations, +}); + export const indexStatusSelector = ({ indexStatus }: AppState) => { return indexStatus; }; diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx index 09156db9ca7d2..fa2998532d145 100644 --- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx +++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx @@ -23,6 +23,8 @@ import { CommonlyUsedRange } from './components/functional/uptime_date_picker'; import { store } from './state'; import { setBasePath } from './state/actions'; import { PageRouter } from './routes'; +import { UptimeAlertsFlyoutWrapper } from './components/connected'; +import { UptimeAlertsContextProvider } from './components/functional/alerts'; import { kibanaService } from './state/kibana_service'; export interface UptimeAppColors { @@ -99,11 +101,14 @@ const Application = (props: UptimeAppProps) => { - -
      - -
      -
      + + +
      + + +
      +
      +
      diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index dd61716325afc..603cfac316b2d 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack"], "id": "uptime", "kibanaVersion": "kibana", - "requiredPlugins": ["features", "licensing", "usageCollection"], + "requiredPlugins": ["alerting", "features", "licensing", "usageCollection"], "server": true, "ui": false, "version": "8.0.0" diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index c7ac3a70c0494..2c1f34aa8a8e7 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -55,5 +55,5 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor }, }); - initUptimeServer(libs); + initUptimeServer(server, libs, plugins); }; diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index 8dde6050d5d36..6fc488e949e9c 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -31,6 +31,8 @@ export interface UptimeCoreSetup { export interface UptimeCorePlugins { features: PluginSetupContract; + alerting: any; + elasticsearch: any; usageCollection: UsageCollectionSetup; } diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts new file mode 100644 index 0000000000000..8a11270a4740a --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -0,0 +1,587 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + contextMessage, + uniqueMonitorIds, + updateState, + statusCheckAlertFactory, + fullListByIdAndLocation, +} from '../status_check'; +import { GetMonitorStatusResult } from '../../requests'; +import { AlertType } from '../../../../../alerting/server'; +import { IRouter } from 'kibana/server'; +import { UMServerLibs } from '../../lib'; +import { UptimeCoreSetup } from '../../adapters'; + +/** + * The alert takes some dependencies as parameters; these are things like + * kibana core services and plugins. This function helps reduce the amount of + * boilerplate required. + * @param customRequests client tests can use this paramter to provide their own request mocks, + * so we don't have to mock them all for each test. + */ +const bootstrapDependencies = (customRequests?: any) => { + const route: IRouter = {} as IRouter; + // these server/libs parameters don't have any functionality, which is fine + // because we aren't testing them here + const server: UptimeCoreSetup = { route }; + const libs: UMServerLibs = { requests: {} } as UMServerLibs; + libs.requests = { ...libs.requests, ...customRequests }; + return { server, libs }; +}; + +/** + * This function aims to provide an easy way to give mock props that will + * reduce boilerplate for tests. + * @param params the params received at alert creation time + * @param services the core services provided by kibana/alerting platforms + * @param state the state the alert maintains + */ +const mockOptions = ( + params = { numTimes: 5, locations: [], timerange: { from: 'now-15m', to: 'now' } }, + services = { callCluster: 'mockESFunction' }, + state = {} +): any => ({ + params, + services, + state, +}); + +describe('status check alert', () => { + describe('executor', () => { + it('does not trigger when there are no monitors down', async () => { + expect.assertions(4); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([]); + const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs); + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(mockOptions()); + + expect(state).not.toBeUndefined(); + expect(state?.isTriggered).toBe(false); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": "mockESFunction", + "locations": Array [], + "numTimes": 5, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + }); + + it('triggers when monitors are down and provides expected state', async () => { + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 234, + status: 'down', + }, + ]); + const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs); + const mockInstanceFactory = jest.fn(); + const mockReplaceState = jest.fn(); + const mockScheduleActions = jest.fn(); + mockInstanceFactory.mockReturnValue({ + replaceState: mockReplaceState, + scheduleActions: mockScheduleActions, + }); + const options = mockOptions(); + options.services = { + ...options.services, + alertInstanceFactory: mockInstanceFactory, + }; + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(options); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockInstanceFactory).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": "mockESFunction", + "locations": Array [], + "numTimes": 5, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + expect(mockReplaceState).toHaveBeenCalledTimes(1); + expect(mockReplaceState.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "monitors": Array [ + Object { + "count": 234, + "location": "fairbanks", + "monitor_id": "first", + "status": "down", + }, + Object { + "count": 234, + "location": "harrisburg", + "monitor_id": "first", + "status": "down", + }, + ], + }, + ] + `); + expect(mockScheduleActions).toHaveBeenCalledTimes(1); + expect(mockScheduleActions.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "xpack.uptime.alerts.actionGroups.monitorStatus", + Object { + "completeIdList": "first from fairbanks; first from harrisburg; ", + "message": "Down monitor: first", + "server": Object { + "route": Object {}, + }, + }, + ] + `); + }); + }); + + describe('fullListByIdAndLocation', () => { + it('renders a list of all monitors', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + ]; + expect(fullListByIdAndLocation(statuses)).toMatchInlineSnapshot( + `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; "` + ); + }); + + it('renders a list of monitors when greater than limit', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + ]; + expect(fullListByIdAndLocation(statuses.slice(0, 2), 1)).toMatchInlineSnapshot( + `"first from fairbanks; ...and 1 other monitor/location"` + ); + }); + + it('renders expected list of monitors when limit difference > 1', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'third', + status: 'down', + count: 34, + }, + { + location: 'fairbanks', + monitor_id: 'third', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + ]; + expect(fullListByIdAndLocation(statuses, 4)).toMatchInlineSnapshot( + `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; ...and 2 other monitors/locations"` + ); + }); + }); + + describe('alert factory', () => { + let alert: AlertType; + + beforeEach(() => { + const { server, libs } = bootstrapDependencies(); + alert = statusCheckAlertFactory(server, libs); + }); + + it('creates an alert with expected params', () => { + // @ts-ignore the `props` key here isn't described + expect(Object.keys(alert.validate?.params?.props ?? {})).toMatchInlineSnapshot(` + Array [ + "filters", + "numTimes", + "timerange", + "locations", + ] + `); + }); + + it('contains the expected static fields like id, name, etc.', () => { + expect(alert.id).toBe('xpack.uptime.alerts.monitorStatus'); + expect(alert.name).toBe('Uptime Monitor Status'); + expect(alert.defaultActionGroupId).toBe('xpack.uptime.alerts.actionGroups.monitorStatus'); + expect(alert.actionGroups).toMatchInlineSnapshot(` + Array [ + Object { + "id": "xpack.uptime.alerts.actionGroups.monitorStatus", + "name": "Uptime Down Monitor", + }, + ] + `); + }); + }); + + describe('updateState', () => { + let spy: jest.SpyInstance; + beforeEach(() => { + spy = jest.spyOn(Date.prototype, 'toISOString'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets initial state values', () => { + spy.mockImplementation(() => 'foo date string'); + const result = updateState({}, false); + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "foo date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "foo date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + }); + + it('updates the correct field in subsequent calls', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string'); + const firstState = updateState({}, false); + const secondState = updateState(firstState, true); + expect(spy).toHaveBeenCalledTimes(2); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "second date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + }); + + it('correctly marks resolution times', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string') + .mockImplementationOnce(() => 'third date string'); + const firstState = updateState({}, true); + const secondState = updateState(firstState, true); + const thirdState = updateState(secondState, false); + expect(spy).toHaveBeenCalledTimes(3); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "first date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": true, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "first date string", + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "first date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + expect(thirdState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": false, + "lastCheckedAt": "third date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "second date string", + } + `); + }); + + it('correctly marks state fields across multiple triggers/resolutions', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string') + .mockImplementationOnce(() => 'third date string') + .mockImplementationOnce(() => 'fourth date string') + .mockImplementationOnce(() => 'fifth date string'); + const firstState = updateState({}, false); + const secondState = updateState(firstState, true); + const thirdState = updateState(secondState, false); + const fourthState = updateState(thirdState, true); + const fifthState = updateState(fourthState, false); + expect(spy).toHaveBeenCalledTimes(5); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "second date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + expect(thirdState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": false, + "lastCheckedAt": "third date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "second date string", + } + `); + expect(fourthState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "fourth date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "fourth date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "fourth date string", + } + `); + expect(fifthState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": false, + "lastCheckedAt": "fifth date string", + "lastResolvedAt": "fifth date string", + "lastTriggeredAt": "fourth date string", + } + `); + }); + }); + + describe('uniqueMonitorIds', () => { + let items: GetMonitorStatusResult[]; + beforeEach(() => { + items = [ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 312, + status: 'down', + }, + { + monitor_id: 'second', + location: 'harrisburg', + count: 325, + status: 'down', + }, + { + monitor_id: 'second', + location: 'fairbanks', + count: 331, + status: 'down', + }, + { + monitor_id: 'third', + location: 'harrisburg', + count: 331, + status: 'down', + }, + { + monitor_id: 'third', + location: 'fairbanks', + count: 342, + status: 'down', + }, + { + monitor_id: 'fourth', + location: 'harrisburg', + count: 355, + status: 'down', + }, + { + monitor_id: 'fourth', + location: 'fairbanks', + count: 342, + status: 'down', + }, + { + monitor_id: 'fifth', + location: 'harrisburg', + count: 342, + status: 'down', + }, + { + monitor_id: 'fifth', + location: 'fairbanks', + count: 342, + status: 'down', + }, + ]; + }); + + it('creates a set of unique IDs from a list of composite-unique objects', () => { + expect(uniqueMonitorIds(items)).toEqual( + new Set(['first', 'second', 'third', 'fourth', 'fifth']) + ); + }); + }); + + describe('contextMessage', () => { + let ids: string[]; + beforeEach(() => { + ids = ['first', 'second', 'third', 'fourth', 'fifth']; + }); + + it('creates a message with appropriate number of monitors', () => { + expect(contextMessage(ids, 3)).toMatchInlineSnapshot( + `"Down monitors: first, second, third... and 2 other monitors"` + ); + }); + + it('throws an error if `max` is less than 2', () => { + expect(() => contextMessage(ids, 1)).toThrowErrorMatchingInlineSnapshot( + '"Maximum value must be greater than 2, received 1."' + ); + }); + + it('returns only the ids if length < max', () => { + expect(contextMessage(ids.slice(0, 2), 3)).toMatchInlineSnapshot( + `"Down monitors: first, second"` + ); + }); + + it('returns a default message when no monitors are provided', () => { + expect(contextMessage([], 3)).toMatchInlineSnapshot(`"No down monitor IDs received"`); + }); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/index.ts b/x-pack/plugins/uptime/server/lib/alerts/index.ts new file mode 100644 index 0000000000000..0e61fd70e0024 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UptimeAlertTypeFactory } from './types'; +import { statusCheckAlertFactory } from './status_check'; + +export const uptimeAlertTypeFactories: UptimeAlertTypeFactory[] = [statusCheckAlertFactory]; diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts new file mode 100644 index 0000000000000..3e90d2ce95a10 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { isRight } from 'fp-ts/lib/Either'; +import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; +import { i18n } from '@kbn/i18n'; +import { AlertExecutorOptions } from '../../../../alerting/server'; +import { ACTION_GROUP_DEFINITIONS } from '../../../../../legacy/plugins/uptime/common/constants'; +import { UptimeAlertTypeFactory } from './types'; +import { GetMonitorStatusResult } from '../requests'; +import { + StatusCheckExecutorParamsType, + StatusCheckAlertStateType, + StatusCheckAlertState, +} from '../../../../../legacy/plugins/uptime/common/runtime_types'; + +const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS; + +/** + * Reduce a composite-key array of status results to a set of unique IDs. + * @param items to reduce + */ +export const uniqueMonitorIds = (items: GetMonitorStatusResult[]): Set => + items.reduce((acc, { monitor_id }) => { + acc.add(monitor_id); + return acc; + }, new Set()); + +/** + * Generates a message to include in contexts of alerts. + * @param monitors the list of monitors to include in the message + * @param max + */ +export const contextMessage = (monitorIds: string[], max: number): string => { + const MIN = 2; + if (max < MIN) throw new Error(`Maximum value must be greater than ${MIN}, received ${max}.`); + + // generate the message + let message; + if (monitorIds.length === 1) { + message = i18n.translate('xpack.uptime.alerts.message.singularTitle', { + defaultMessage: 'Down monitor: ', + }); + } else if (monitorIds.length) { + message = i18n.translate('xpack.uptime.alerts.message.multipleTitle', { + defaultMessage: 'Down monitors: ', + }); + } + // this shouldn't happen because the function should only be called + // when > 0 monitors are down + else { + message = i18n.translate('xpack.uptime.alerts.message.emptyTitle', { + defaultMessage: 'No down monitor IDs received', + }); + } + + for (let i = 0; i < monitorIds.length; i++) { + const id = monitorIds[i]; + if (i === max) { + return ( + message + + i18n.translate('xpack.uptime.alerts.message.overflowBody', { + defaultMessage: `... and {overflowCount} other monitors`, + values: { + overflowCount: monitorIds.length - i, + }, + }) + ); + } else if (i === 0) { + message = message + id; + } else { + message = message + `, ${id}`; + } + } + + return message; +}; + +/** + * Creates an exhaustive list of all the down monitors. + * @param list all the monitors that are down + * @param sizeLimit the max monitors, we shouldn't allow an arbitrarily long string + */ +export const fullListByIdAndLocation = ( + list: GetMonitorStatusResult[], + sizeLimit: number = 1000 +) => { + return ( + list + // sort by id, then location + .sort((a, b) => { + if (a.monitor_id > b.monitor_id) { + return 1; + } else if (a.monitor_id < b.monitor_id) { + return -1; + } else if (a.location > b.location) { + return 1; + } + return -1; + }) + .slice(0, sizeLimit) + .reduce((cur, { monitor_id: id, location }) => cur + `${id} from ${location}; `, '') + + (sizeLimit < list.length + ? i18n.translate('xpack.uptime.alerts.message.fullListOverflow', { + defaultMessage: '...and {overflowCount} other {pluralizedMonitor}', + values: { + pluralizedMonitor: + list.length - sizeLimit === 1 ? 'monitor/location' : 'monitors/locations', + overflowCount: list.length - sizeLimit, + }, + }) + : '') + ); +}; + +export const updateState = ( + state: Record, + isTriggeredNow: boolean +): StatusCheckAlertState => { + const now = new Date().toISOString(); + const decoded = StatusCheckAlertStateType.decode(state); + if (!isRight(decoded)) { + const triggerVal = isTriggeredNow ? now : undefined; + return { + currentTriggerStarted: triggerVal, + firstCheckedAt: now, + firstTriggeredAt: triggerVal, + isTriggered: isTriggeredNow, + lastTriggeredAt: triggerVal, + lastCheckedAt: now, + lastResolvedAt: undefined, + }; + } + const { + currentTriggerStarted, + firstCheckedAt, + firstTriggeredAt, + lastTriggeredAt, + // this is the stale trigger status, we're naming it `wasTriggered` + // to differentiate it from the `isTriggeredNow` param + isTriggered: wasTriggered, + lastResolvedAt, + } = decoded.right; + + let cts: string | undefined; + if (isTriggeredNow && !currentTriggerStarted) { + cts = now; + } else if (isTriggeredNow) { + cts = currentTriggerStarted; + } + + return { + currentTriggerStarted: cts, + firstCheckedAt: firstCheckedAt ?? now, + firstTriggeredAt: isTriggeredNow && !firstTriggeredAt ? now : firstTriggeredAt, + lastCheckedAt: now, + lastTriggeredAt: isTriggeredNow ? now : lastTriggeredAt, + lastResolvedAt: !isTriggeredNow && wasTriggered ? now : lastResolvedAt, + isTriggered: isTriggeredNow, + }; +}; + +// Right now the maximum number of monitors shown in the message is hardcoded here. +// we might want to make this a parameter in the future +const DEFAULT_MAX_MESSAGE_ROWS = 3; + +export const statusCheckAlertFactory: UptimeAlertTypeFactory = (server, libs) => ({ + id: 'xpack.uptime.alerts.monitorStatus', + name: i18n.translate('xpack.uptime.alerts.monitorStatus', { + defaultMessage: 'Uptime Monitor Status', + }), + validate: { + params: schema.object({ + filters: schema.maybe(schema.string()), + numTimes: schema.number(), + timerange: schema.object({ + from: schema.string(), + to: schema.string(), + }), + locations: schema.arrayOf(schema.string()), + }), + }, + defaultActionGroupId: MONITOR_STATUS.id, + actionGroups: [ + { + id: MONITOR_STATUS.id, + name: MONITOR_STATUS.name, + }, + ], + async executor(options: AlertExecutorOptions) { + const { params: rawParams } = options; + const decoded = StatusCheckExecutorParamsType.decode(rawParams); + if (!isRight(decoded)) { + ThrowReporter.report(decoded); + return { + error: 'Alert param types do not conform to required shape.', + }; + } + + const params = decoded.right; + + /* This is called `monitorsByLocation` but it's really + * monitors by location by status. The query we run to generate this + * filters on the status field, so effectively there should be one and only one + * status represented in the result set. */ + const monitorsByLocation = await libs.requests.getMonitorStatus({ + callES: options.services.callCluster, + ...params, + }); + + // if no monitors are down for our query, we don't need to trigger an alert + if (monitorsByLocation.length) { + const uniqueIds = uniqueMonitorIds(monitorsByLocation); + const alertInstance = options.services.alertInstanceFactory(MONITOR_STATUS.id); + alertInstance.replaceState({ + ...options.state, + monitors: monitorsByLocation, + }); + alertInstance.scheduleActions(MONITOR_STATUS.id, { + message: contextMessage(Array.from(uniqueIds.keys()), DEFAULT_MAX_MESSAGE_ROWS), + server, + completeIdList: fullListByIdAndLocation(monitorsByLocation), + }); + } + + // this stateful data is at the cluster level, not an alert instance level, + // so any alert of this type will flush/overwrite the state when they return + return updateState(options.state, monitorsByLocation.length > 0); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts new file mode 100644 index 0000000000000..bc1e82224f7b0 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertType } from '../../../../alerting/server'; +import { UptimeCoreSetup } from '../adapters'; +import { UMServerLibs } from '../lib'; + +export type UptimeAlertTypeFactory = (server: UptimeCoreSetup, libs: UMServerLibs) => AlertType; diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts new file mode 100644 index 0000000000000..74b8c352c8553 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts @@ -0,0 +1,553 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; +import { getMonitorStatus } from '../get_monitor_status'; +import { ScopedClusterClient } from 'src/core/server/elasticsearch'; + +interface BucketItemCriteria { + monitor_id: string; + status: string; + location: string; + doc_count: number; +} + +interface BucketKey { + monitor_id: string; + status: string; + location: string; +} + +interface BucketItem { + key: BucketKey; + doc_count: number; +} + +interface MultiPageCriteria { + after_key?: BucketKey; + bucketCriteria: BucketItemCriteria[]; +} + +const genBucketItem = ({ + monitor_id, + status, + location, + doc_count, +}: BucketItemCriteria): BucketItem => ({ + key: { + monitor_id, + status, + location, + }, + doc_count, +}); + +type MockCallES = (method: any, params: any) => Promise; + +const setupMock = ( + criteria: MultiPageCriteria[] +): [MockCallES, jest.Mocked>] => { + const esMock = elasticsearchServiceMock.createScopedClusterClient(); + + criteria.forEach(({ after_key, bucketCriteria }) => { + const mockResponse = { + aggregations: { + monitors: { + after_key, + buckets: bucketCriteria.map(item => genBucketItem(item)), + }, + }, + }; + esMock.callAsCurrentUser.mockResolvedValueOnce(mockResponse); + }); + return [(method: any, params: any) => esMock.callAsCurrentUser(method, params), esMock]; +}; + +describe('getMonitorStatus', () => { + it('applies bool filters to params', async () => { + const [callES, esMock] = setupMock([]); + const exampleFilter = `{ + "bool": { + "should": [ + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "apm-dev" + } + } + ], + "minimum_should_match": 1 + } + }, + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A" + } + } + ], + "minimum_should_match": 1 + } + } + ], + "minimum_should_match": 1 + } + }`; + await getMonitorStatus({ + callES, + filters: exampleFilter, + locations: [], + numTimes: 5, + timerange: { + from: 'now-10m', + to: 'now-1m', + }, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-10m", + "lte": "now-1m", + }, + }, + }, + ], + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "apm-dev", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('applies locations to params', async () => { + const [callES, esMock] = setupMock([]); + await getMonitorStatus({ + callES, + locations: ['fairbanks', 'harrisburg'], + numTimes: 1, + timerange: { + from: 'now-2m', + to: 'now', + }, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-2m", + "lte": "now", + }, + }, + }, + Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "harrisburg", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('fetches single page of results', async () => { + const [callES, esMock] = setupMock([ + { + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + ]); + const clientParameters = { + filters: undefined, + locations: [], + numTimes: 5, + timerange: { + from: 'now-12m', + to: 'now-2m', + }, + }; + const result = await getMonitorStatus({ + callES, + ...clientParameters, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-12m", + "lte": "now-2m", + }, + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 43, + "location": "fairbanks", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 53, + "location": "harrisburg", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 44, + "location": "harrisburg", + "monitor_id": "foo", + "status": "down", + }, + ] + `); + }); + + it('fetches multiple pages of results in the thing', async () => { + const criteria = [ + { + after_key: { + monitor_id: 'foo', + location: 'harrisburg', + status: 'down', + }, + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + { + after_key: { + monitor_id: 'bar', + status: 'down', + location: 'fairbanks', + }, + bucketCriteria: [ + { + monitor_id: 'sna', + status: 'down', + location: 'fairbanks', + doc_count: 21, + }, + { + monitor_id: 'fu', + status: 'down', + location: 'fairbanks', + doc_count: 21, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'fairbanks', + doc_count: 45, + }, + ], + }, + { + bucketCriteria: [ + { + monitor_id: 'sna', + status: 'down', + location: 'harrisburg', + doc_count: 21, + }, + { + monitor_id: 'fu', + status: 'down', + location: 'harrisburg', + doc_count: 21, + }, + ], + }, + ]; + const [callES] = setupMock(criteria); + const result = await getMonitorStatus({ + callES, + locations: [], + numTimes: 5, + timerange: { + from: 'now-10m', + to: 'now-1m', + }, + }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 43, + "location": "fairbanks", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 53, + "location": "harrisburg", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 44, + "location": "harrisburg", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 21, + "location": "fairbanks", + "monitor_id": "sna", + "status": "down", + }, + Object { + "count": 21, + "location": "fairbanks", + "monitor_id": "fu", + "status": "down", + }, + Object { + "count": 45, + "location": "fairbanks", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 21, + "location": "harrisburg", + "monitor_id": "sna", + "status": "down", + }, + Object { + "count": 21, + "location": "harrisburg", + "monitor_id": "fu", + "status": "down", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts new file mode 100644 index 0000000000000..2cebd532fd29b --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UMElasticsearchQueryFn } from '../adapters'; +import { INDEX_NAMES } from '../../../../../legacy/plugins/uptime/common/constants'; + +export interface GetMonitorStatusParams { + filters?: string; + locations: string[]; + numTimes: number; + timerange: { from: string; to: string }; +} + +export interface GetMonitorStatusResult { + monitor_id: string; + status: string; + location: string; + count: number; +} + +interface MonitorStatusKey { + monitor_id: string; + status: string; + location: string; +} + +const formatBuckets = async ( + buckets: any[], + numTimes: number +): Promise => { + return buckets + .filter((monitor: any) => monitor?.doc_count > numTimes) + .map(({ key, doc_count }: any) => ({ ...key, count: doc_count })); +}; + +const getLocationClause = (locations: string[]) => ({ + bool: { + should: [ + ...locations.map(location => ({ + term: { + 'observer.geo.name': location, + }, + })), + ], + }, +}); + +export const getMonitorStatus: UMElasticsearchQueryFn< + GetMonitorStatusParams, + GetMonitorStatusResult[] +> = async ({ callES, filters, locations, numTimes, timerange: { from, to } }) => { + const queryResults: Array> = []; + let afterKey: MonitorStatusKey | undefined; + + do { + // today this value is hardcoded. In the future we may support + // multiple status types for this alert, and this will become a parameter + const STATUS = 'down'; + const esParams: any = { + index: INDEX_NAMES.HEARTBEAT, + body: { + query: { + bool: { + filter: [ + { + term: { + 'monitor.status': STATUS, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + size: 0, + aggs: { + monitors: { + composite: { + size: 2000, + sources: [ + { + monitor_id: { + terms: { + field: 'monitor.id', + }, + }, + }, + { + status: { + terms: { + field: 'monitor.status', + }, + }, + }, + { + location: { + terms: { + field: 'observer.geo.name', + missing_bucket: true, + }, + }, + }, + ], + }, + }, + }, + }, + }; + + /** + * `filters` are an unparsed JSON string. We parse them and append the bool fields of the query + * to the bool of the parsed filters. + */ + if (filters) { + const parsedFilters = JSON.parse(filters); + esParams.body.query.bool = Object.assign({}, esParams.body.query.bool, parsedFilters.bool); + } + + /** + * Perform a logical `and` against the selected location filters. + */ + if (locations.length) { + esParams.body.query.bool.filter.push(getLocationClause(locations)); + } + + /** + * We "paginate" results by utilizing the `afterKey` field + * to tell Elasticsearch where it should start on subsequent queries. + */ + if (afterKey) { + esParams.body.aggs.monitors.composite.after = afterKey; + } + + const result = await callES('search', esParams); + afterKey = result?.aggregations?.monitors?.after_key; + + queryResults.push(formatBuckets(result?.aggregations?.monitors?.buckets || [], numTimes)); + } while (afterKey !== undefined); + + return (await Promise.all(queryResults)).reduce((acc, cur) => acc.concat(cur), []); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index b1d7ff2c2ce02..7225d329d3c7f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -12,6 +12,8 @@ export { getMonitorDurationChart, GetMonitorChartsParams } from './get_monitor_d export { getMonitorDetails, GetMonitorDetailsParams } from './get_monitor_details'; export { getMonitorLocations, GetMonitorLocationsParams } from './get_monitor_locations'; export { getMonitorStates, GetMonitorStatesParams } from './get_monitor_states'; +export { getMonitorStatus, GetMonitorStatusParams } from './get_monitor_status'; +export * from './get_monitor_status'; export { getPings, GetPingsParams } from './get_pings'; export { getPingHistogram, GetPingHistogramParams } from './get_ping_histogram'; export { UptimeRequests } from './uptime_requests'; diff --git a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts index 7f192994bd075..ddf506786f145 100644 --- a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts +++ b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts @@ -16,6 +16,8 @@ import { GetMonitorStatesParams, GetPingsParams, GetPingHistogramParams, + GetMonitorStatusParams, + GetMonitorStatusResult, } from '.'; import { OverviewFilters, @@ -42,6 +44,7 @@ export interface UptimeRequests { getMonitorDetails: ESQ; getMonitorLocations: ESQ; getMonitorStates: ESQ; + getMonitorStatus: ESQ; getPings: ESQ; getPingHistogram: ESQ; getSnapshotCount: ESQ; diff --git a/x-pack/plugins/uptime/server/uptime_server.ts b/x-pack/plugins/uptime/server/uptime_server.ts index 4dfa1373db8d9..d4b38b8ad27a0 100644 --- a/x-pack/plugins/uptime/server/uptime_server.ts +++ b/x-pack/plugins/uptime/server/uptime_server.ts @@ -8,12 +8,22 @@ import { makeExecutableSchema } from 'graphql-tools'; import { DEFAULT_GRAPHQL_PATH, resolvers, typeDefs } from './graphql'; import { UMServerLibs } from './lib/lib'; import { createRouteWithAuth, restApiRoutes, uptimeRouteWrapper } from './rest_api'; +import { UptimeCoreSetup, UptimeCorePlugins } from './lib/adapters'; +import { uptimeAlertTypeFactories } from './lib/alerts'; -export const initUptimeServer = (libs: UMServerLibs) => { +export const initUptimeServer = ( + server: UptimeCoreSetup, + libs: UMServerLibs, + plugins: UptimeCorePlugins +) => { restApiRoutes.forEach(route => libs.framework.registerRoute(uptimeRouteWrapper(createRouteWithAuth(libs, route))) ); + uptimeAlertTypeFactories.forEach(alertTypeFactory => + plugins.alerting.registerType(alertTypeFactory(server, libs)) + ); + const graphQLSchema = makeExecutableSchema({ resolvers: resolvers.map(createResolversFn => createResolversFn(libs)), typeDefs, diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index f6e93cd14e497..57842ffbb2c5d 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -24,11 +24,13 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo public async goToUptimeOverviewAndLoadData( datePickerStartValue: string, datePickerEndValue: string, - monitorIdToCheck: string + monitorIdToCheck?: string ) { await pageObjects.common.navigateToApp('uptime'); await pageObjects.timePicker.setAbsoluteRange(datePickerStartValue, datePickerEndValue); - await uptimeService.monitorIdExists(monitorIdToCheck); + if (monitorIdToCheck) { + await uptimeService.monitorIdExists(monitorIdToCheck); + } } public async loadDataAndGoToMonitorPage( @@ -96,5 +98,39 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo public locationMissingIsDisplayed() { return uptimeService.locationMissingExists(); } + + public async openAlertFlyoutAndCreateMonitorStatusAlert({ + alertInterval, + alertName, + alertNumTimes, + alertTags, + alertThrottleInterval, + alertTimerangeSelection, + filters, + }: { + alertName: string; + alertTags: string[]; + alertInterval: string; + alertThrottleInterval: string; + alertNumTimes: string; + alertTimerangeSelection: string; + filters?: string; + }) { + const { alerts, setKueryBarText } = uptimeService; + await alerts.openFlyout(); + await alerts.openMonitorStatusAlertType(); + await alerts.setAlertName(alertName); + await alerts.setAlertTags(alertTags); + await alerts.setAlertInterval(alertInterval); + await alerts.setAlertThrottleInterval(alertThrottleInterval); + if (filters) { + await setKueryBarText('xpack.uptime.alerts.monitorStatus.filterBar', filters); + } + await alerts.setAlertStatusNumTimes(alertNumTimes); + await alerts.setAlertTimerangeSelection(alertTimerangeSelection); + await alerts.setMonitorStatusSelectableToHours(); + await alerts.setLocationsSelectable(); + await alerts.clickSaveAlertButtion(); + } })(); } diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 938be2c71ae74..7994a7e934033 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -12,6 +12,91 @@ export function UptimeProvider({ getService }: FtrProviderContext) { const retry = getService('retry'); return { + alerts: { + async openFlyout() { + await testSubjects.click('xpack.uptime.alertsPopover.toggleButton', 5000); + await testSubjects.click('xpack.uptime.toggleAlertFlyout', 5000); + }, + async openMonitorStatusAlertType() { + return testSubjects.click('xpack.uptime.alerts.monitorStatus-SelectOption', 5000); + }, + async setAlertTags(tags: string[]) { + for (let i = 0; i < tags.length; i += 1) { + await testSubjects.click('comboBoxSearchInput', 5000); + await testSubjects.setValue('comboBoxInput', tags[i]); + await browser.pressKeys(browser.keys.ENTER); + } + }, + async setAlertName(name: string) { + return testSubjects.setValue('alertNameInput', name); + }, + async setAlertInterval(value: string) { + return testSubjects.setValue('intervalInput', value); + }, + async setAlertThrottleInterval(value: string) { + return testSubjects.setValue('throttleInput', value); + }, + async setAlertExpressionValue( + expressionAttribute: string, + fieldAttribute: string, + value: string + ) { + await testSubjects.click(expressionAttribute); + await testSubjects.setValue(fieldAttribute, value); + return browser.pressKeys(browser.keys.ESCAPE); + }, + async setAlertStatusNumTimes(value: string) { + return this.setAlertExpressionValue( + 'xpack.uptime.alerts.monitorStatus.numTimesExpression', + 'xpack.uptime.alerts.monitorStatus.numTimesField', + value + ); + }, + async setAlertTimerangeSelection(value: string) { + return this.setAlertExpressionValue( + 'xpack.uptime.alerts.monitorStatus.timerangeValueExpression', + 'xpack.uptime.alerts.monitorStatus.timerangeValueField', + value + ); + }, + async setAlertExpressionSelectable( + expressionAttribute: string, + selectableAttribute: string, + optionAttributes: string[] + ) { + await testSubjects.click(expressionAttribute, 5000); + await testSubjects.click(selectableAttribute, 5000); + for (let i = 0; i < optionAttributes.length; i += 1) { + await testSubjects.click(optionAttributes[i], 5000); + } + return browser.pressKeys(browser.keys.ESCAPE); + }, + async setMonitorStatusSelectableToHours() { + return this.setAlertExpressionSelectable( + 'xpack.uptime.alerts.monitorStatus.timerangeUnitExpression', + 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable', + ['xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption'] + ); + }, + async setLocationsSelectable() { + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionExpression', + 5000 + ); + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionSwitch', + 5000 + ); + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionSelectable', + 5000 + ); + return browser.pressKeys(browser.keys.ESCAPE); + }, + async clickSaveAlertButtion() { + return testSubjects.click('saveAlertButton'); + }, + }, async assertExists(key: string) { if (!(await testSubjects.exists(key))) { throw new Error(`Couldn't find expected element with key "${key}".`); @@ -35,11 +120,14 @@ export function UptimeProvider({ getService }: FtrProviderContext) { async getMonitorNameDisplayedOnPageTitle() { return await testSubjects.getVisibleText('monitor-page-title'); }, - async setFilterText(filterQuery: string) { - await testSubjects.click('xpack.uptime.filterBar'); - await testSubjects.setValue('xpack.uptime.filterBar', filterQuery); + async setKueryBarText(attribute: string, value: string) { + await testSubjects.click(attribute); + await testSubjects.setValue(attribute, value); await browser.pressKeys(browser.keys.ENTER); }, + async setFilterText(filterQuery: string) { + await this.setKueryBarText('xpack.uptime.filterBar', filterQuery); + }, async goToNextPage() { await testSubjects.click('xpack.uptime.monitorList.nextButton', 5000); }, diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts new file mode 100644 index 0000000000000..2a0358160da51 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + describe('overview page alert flyout controls', function() { + const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; + const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; + const pageObjects = getPageObjects(['common', 'uptime']); + const supertest = getService('supertest'); + const retry = getService('retry'); + + it('posts an alert, verfies its presence, and deletes the alert', async () => { + await pageObjects.uptime.goToUptimeOverviewAndLoadData(DEFAULT_DATE_START, DEFAULT_DATE_END); + + await pageObjects.uptime.openAlertFlyoutAndCreateMonitorStatusAlert({ + alertInterval: '11', + alertName: 'uptime-test', + alertNumTimes: '3', + alertTags: ['uptime', 'another'], + alertThrottleInterval: '30', + alertTimerangeSelection: '1', + filters: 'monitor.id: "0001-up"', + }); + + // The creation of the alert could take some time, so the first few times we query after + // the previous line resolves, the API may not be done creating the alert yet, so we + // put the fetch code in a retry block with a timeout. + let alert: any; + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get('/api/alert/_find'); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === 'uptime-test' + ); + expect(alertsFromThisTest).to.have.length(1); + alert = alertsFromThisTest[0]; + }); + + // Ensure the parameters and other stateful data + // on the alert match up with the values we provided + // for our test helper to input into the flyout. + const { + actions, + alertTypeId, + consumer, + id, + params: { numTimes, timerange, locations, filters }, + schedule: { interval }, + tags, + } = alert; + + // we're not testing the flyout's ability to associate alerts with action connectors + expect(actions).to.eql([]); + + expect(alertTypeId).to.eql('xpack.uptime.alerts.monitorStatus'); + expect(consumer).to.eql('uptime'); + expect(interval).to.eql('11m'); + expect(tags).to.eql(['uptime', 'another']); + expect(numTimes).to.be(3); + expect(timerange.from).to.be('now-1h'); + expect(timerange.to).to.be('now'); + expect(locations).to.eql(['mpls']); + expect(filters).to.eql( + '{"bool":{"should":[{"match_phrase":{"monitor.id":"0001-up"}}],"minimum_should_match":1}}' + ); + + await supertest + .delete(`/api/alert/${id}`) + .set('kbn-xsrf', 'true') + .expect(204); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts new file mode 100644 index 0000000000000..a433175acae01 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FtrProviderContext } from '../../ftr_provider_context'; + +const ARCHIVE = 'uptime/full_heartbeat'; + +export default ({ getService, loadTestFile }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + describe('Uptime app', function() { + this.tags('ciGroup6'); + + describe('with real-world data', () => { + before(async () => { + await esArchiver.load(ARCHIVE); + await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'UTC' }); + }); + after(async () => await esArchiver.unload(ARCHIVE)); + + loadTestFile(require.resolve('./alert_flyout')); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index b19ec95c68916..538817bd9d14c 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -28,7 +28,10 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { services, pageObjects, // list paths to the files that contain your plugins tests - testFiles: [resolve(__dirname, './apps/triggers_actions_ui')], + testFiles: [ + resolve(__dirname, './apps/triggers_actions_ui'), + resolve(__dirname, './apps/uptime'), + ], apps: { ...xpackFunctionalConfig.get('apps'), triggersActions: { From 3bd3364a5567969558245cdfa5a7381ee4ae7c83 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Thu, 19 Mar 2020 09:58:22 -0700 Subject: [PATCH 13/13] [Canvas] Add Lens embeddables (#57499) * Added lens embeddables to embed flyout Fixed import embedded panel styles (#58654) Merging to WIP draft branch * Added i18n strings for savedLens * Added tests for lens embeddables * Updated tests * Updated tests * Added style overrides for lens table * DDisables triggers on lens emebeddable * Updated test * Sets embeddable view mode according to app state * Fix embeddable component * Removed embeddable view mode logic * Removed unused import --- .../expression_types/embeddable_types.ts | 9 +- .../functions/common/index.ts | 2 + .../functions/common/saved_lens.test.ts | 43 ++++++++++ .../functions/common/saved_lens.ts | 83 +++++++++++++++++++ .../renderers/embeddable/embeddable.scss | 33 ++++++++ .../renderers/embeddable/embeddable.tsx | 7 +- .../embeddable_input_to_expression.test.ts | 48 ++++++++++- .../embeddable_input_to_expression.ts | 19 +++++ .../plugins/canvas/common/lib/constants.ts | 1 + .../canvas/i18n/functions/dict/saved_lens.ts | 27 ++++++ .../canvas/i18n/functions/function_help.ts | 2 + .../components/embeddable_flyout/index.tsx | 3 + .../workpad_interactive_page/index.js | 10 ++- .../plugins/canvas/public/style/index.scss | 1 + x-pack/plugins/lens/common/constants.ts | 1 + 15 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss create mode 100644 x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts index d9e841092be56..538aa9f74e2a6 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts @@ -7,9 +7,16 @@ // @ts-ignore import { MAP_SAVED_OBJECT_TYPE } from '../../../maps/common/constants'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/visualizations/public'; +import { LENS_EMBEDDABLE_TYPE } from '../../../../../plugins/lens/common/constants'; import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants'; -export const EmbeddableTypes: { map: string; search: string; visualization: string } = { +export const EmbeddableTypes: { + lens: string; + map: string; + search: string; + visualization: string; +} = { + lens: LENS_EMBEDDABLE_TYPE, map: MAP_SAVED_OBJECT_TYPE, search: SEARCH_EMBEDDABLE_TYPE, visualization: VISUALIZE_EMBEDDABLE_TYPE, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 48b50930d563e..36fa6497ab6f3 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -48,6 +48,7 @@ import { rounddate } from './rounddate'; import { rowCount } from './rowCount'; import { repeatImage } from './repeatImage'; import { revealImage } from './revealImage'; +import { savedLens } from './saved_lens'; import { savedMap } from './saved_map'; import { savedSearch } from './saved_search'; import { savedVisualization } from './saved_visualization'; @@ -109,6 +110,7 @@ export const functions = [ revealImage, rounddate, rowCount, + savedLens, savedMap, savedSearch, savedVisualization, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts new file mode 100644 index 0000000000000..6b197148e6373 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.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; + * you may not use this file except in compliance with the Elastic License. + */ +jest.mock('ui/new_platform'); +import { savedLens } from './saved_lens'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; + +const filterContext = { + and: [ + { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, + { + and: [], + column: 'time-column', + type: 'time', + from: '2019-06-04T04:00:00.000Z', + to: '2019-06-05T04:00:00.000Z', + }, + ], +}; + +describe('savedLens', () => { + const fn = savedLens().fn; + const args = { + id: 'some-id', + title: null, + timerange: null, + }; + + it('accepts null context', () => { + const expression = fn(null, args, {} as any); + + expect(expression.input.filters).toEqual([]); + }); + + it('accepts filter context', () => { + const expression = fn(filterContext, args, {} as any); + const embeddableFilters = getQueryFilters(filterContext.and); + + expect(expression.input.filters).toEqual(embeddableFilters); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts new file mode 100644 index 0000000000000..60026adc0998a --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { TimeRange } from 'src/plugins/data/public'; +import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { Filter, TimeRange as TimeRangeArg } from '../../../types'; +import { + EmbeddableTypes, + EmbeddableExpressionType, + EmbeddableExpression, +} from '../../expression_types'; +import { getFunctionHelp } from '../../../i18n'; +import { Filter as DataFilter } from '../../../../../../../src/plugins/data/public'; + +interface Arguments { + id: string; + title: string | null; + timerange: TimeRangeArg | null; +} + +export type SavedLensInput = EmbeddableInput & { + id: string; + timeRange?: TimeRange; + filters: DataFilter[]; +}; + +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; + +type Return = EmbeddableExpression; + +export function savedLens(): ExpressionFunctionDefinition< + 'savedLens', + Filter | null, + Arguments, + Return +> { + const { help, args: argHelp } = getFunctionHelp().savedLens; + return { + name: 'savedLens', + help, + args: { + id: { + types: ['string'], + required: false, + help: argHelp.id, + }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + title: { + types: ['string'], + help: argHelp.title, + required: false, + }, + }, + type: EmbeddableExpressionType, + fn: (context, args) => { + const filters = context ? context.and : []; + + return { + type: EmbeddableExpressionType, + input: { + id: args.id, + filters: getQueryFilters(filters), + timeRange: args.timerange || defaultTimeRange, + title: args.title ? args.title : undefined, + disableTriggers: true, + }, + embeddableType: EmbeddableTypes.lens, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss new file mode 100644 index 0000000000000..04f2f393d1e80 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss @@ -0,0 +1,33 @@ +.canvasEmbeddable { + .embPanel { + border: none; + background: none; + + .embPanel__title { + margin-bottom: $euiSizeXS; + } + + .embPanel__optionsMenuButton { + border-radius: $euiBorderRadius; + } + + .canvas-isFullscreen & { + .embPanel__optionsMenuButton { + opacity: 0; + } + + &:focus .embPanel__optionsMenuButton, + &:hover .embPanel__optionsMenuButton { + opacity: 1; + } + } + } + + .euiTable { + background: none; + } + + .lnsExpressionRenderer { + @include euiScrollBar; + } +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 549e69e57e921..d91e70e43bfd5 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -18,11 +18,12 @@ import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_a import { EmbeddableExpression } from '../../expression_types/embeddable'; import { RendererStrings } from '../../../i18n'; import { getSavedObjectFinder } from '../../../../../../../src/plugins/saved_objects/public'; - -const { embeddable: strings } = RendererStrings; import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { EmbeddableInput } from '../../expression_types'; import { RendererHandlers } from '../../../types'; +import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib'; + +const { embeddable: strings } = RendererStrings; const embeddablesRegistry: { [key: string]: IEmbeddable; @@ -31,7 +32,7 @@ const embeddablesRegistry: { const renderEmbeddable = (embeddableObject: IEmbeddable, domNode: HTMLElement) => { return (
      diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts index 8694c0e2c7f9f..4c622b0c247fa 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -7,12 +7,17 @@ jest.mock('ui/new_platform'); import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { SavedMapInput } from '../../functions/common/saved_map'; +import { SavedLensInput } from '../../functions/common/saved_lens'; import { EmbeddableTypes } from '../../expression_types'; import { fromExpression, Ast } from '@kbn/interpreter/common'; -const baseSavedMapInput = { +const baseEmbeddableInput = { id: 'embeddableId', filters: [], +}; + +const baseSavedMapInput = { + ...baseEmbeddableInput, isLayerTOCOpen: false, refreshConfig: { isPaused: true, @@ -73,4 +78,45 @@ describe('input to expression', () => { expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); }); }); + + describe('Lens Embeddable', () => { + it('converts to a savedLens expression', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.lens); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedLens'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); + expect(ast.chain[0].arguments).toHaveProperty('timerange'); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts index a3cb53acebed2..6428507b16a0c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -6,6 +6,7 @@ import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; import { SavedMapInput } from '../../functions/common/saved_map'; +import { SavedLensInput } from '../../functions/common/saved_lens'; /* Take the input from an embeddable and the type of embeddable and convert it into an expression @@ -46,5 +47,23 @@ export function embeddableInputToExpression( } } + if (embeddableType === EmbeddableTypes.lens) { + const lensInput = input as SavedLensInput; + + expressionParts.push('savedLens'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (lensInput.timeRange) { + expressionParts.push( + `timerange={timerange from="${lensInput.timeRange.from}" to="${lensInput.timeRange.to}"}` + ); + } + } + return expressionParts.join(' '); } diff --git a/x-pack/legacy/plugins/canvas/common/lib/constants.ts b/x-pack/legacy/plugins/canvas/common/lib/constants.ts index 40e143b9ec589..ac8e80b8d7b89 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/constants.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/constants.ts @@ -39,3 +39,4 @@ export const API_ROUTE_SHAREABLE_BASE = '/public/canvas'; export const API_ROUTE_SHAREABLE_ZIP = '/public/canvas/zip'; export const API_ROUTE_SHAREABLE_RUNTIME = '/public/canvas/runtime'; export const API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD = `/public/canvas/${SHAREABLE_RUNTIME_NAME}.js`; +export const CANVAS_EMBEDDABLE_CLASSNAME = `canvasEmbeddable`; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts new file mode 100644 index 0000000000000..1efcbc9d3a18e --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { savedLens } from '../../../canvas_plugin_src/functions/common/saved_lens'; +import { FunctionHelp } from '../function_help'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.savedLensHelpText', { + defaultMessage: `Returns an embeddable for a saved lens object`, + }), + args: { + id: i18n.translate('xpack.canvas.functions.savedLens.args.idHelpText', { + defaultMessage: `The ID of the Saved Lens Object`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedLens.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + title: i18n.translate('xpack.canvas.functions.savedLens.args.titleHelpText', { + defaultMessage: `The title for the lens emebeddable`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts index dbdadd09df67f..e7d7b4ca4321b 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts @@ -62,6 +62,7 @@ import { help as replace } from './dict/replace'; import { help as revealImage } from './dict/reveal_image'; import { help as rounddate } from './dict/rounddate'; import { help as rowCount } from './dict/row_count'; +import { help as savedLens } from './dict/saved_lens'; import { help as savedMap } from './dict/saved_map'; import { help as savedSearch } from './dict/saved_search'; import { help as savedVisualization } from './dict/saved_visualization'; @@ -216,6 +217,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ revealImage, rounddate, rowCount, + savedLens, savedMap, savedSearch, savedVisualization, diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index 565ca5fa5bbd6..353a59397d6b6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -21,6 +21,9 @@ const allowedEmbeddables = { [EmbeddableTypes.map]: (id: string) => { return `savedMap id="${id}" | render`; }, + [EmbeddableTypes.lens]: (id: string) => { + return `savedLens id="${id}" | render`; + }, // FIX: Only currently allow Map embeddables /* [EmbeddableTypes.visualization]: (id: string) => { return `filters | savedVisualization id="${id}" | render`; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js index b775524acf639..2500a412c0fac 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js @@ -19,6 +19,7 @@ import { } from '../../../state/actions/elements'; import { selectToplevelNodes } from '../../../state/actions/transient'; import { crawlTree, globalStateUpdater, shapesForNodes } from '../integration_utils'; +import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../../common/lib'; import { InteractiveWorkpadPage as InteractiveComponent } from './interactive_workpad_page'; import { eventHandlers } from './event_handlers'; @@ -79,9 +80,14 @@ const isEmbeddableBody = element => { const hasClosest = typeof element.closest === 'function'; if (hasClosest) { - return element.closest('.embeddable') && !element.closest('.embPanel__header'); + return ( + element.closest(`.${CANVAS_EMBEDDABLE_CLASSNAME}`) && !element.closest('.embPanel__header') + ); } else { - return closest.call(element, '.embeddable') && !closest.call(element, '.embPanel__header'); + return ( + closest.call(element, `.${CANVAS_EMBEDDABLE_CLASSNAME}`) && + !closest.call(element, '.embPanel__header') + ); } }; diff --git a/x-pack/legacy/plugins/canvas/public/style/index.scss b/x-pack/legacy/plugins/canvas/public/style/index.scss index 4b85620863692..39e5903ff1d96 100644 --- a/x-pack/legacy/plugins/canvas/public/style/index.scss +++ b/x-pack/legacy/plugins/canvas/public/style/index.scss @@ -61,6 +61,7 @@ @import '../../canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.scss'; @import '../../canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.scss'; +@import '../../canvas_plugin_src/renderers/embeddable/embeddable.scss'; @import '../../canvas_plugin_src/renderers/plot/plot.scss'; @import '../../canvas_plugin_src/renderers/reveal_image/reveal_image.scss'; @import '../../canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.scss'; diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 57f2a633e4524..16ae1b8da752b 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -5,6 +5,7 @@ */ export const PLUGIN_ID = 'lens'; +export const LENS_EMBEDDABLE_TYPE = 'lens'; export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; export const BASE_APP_URL = '/app/kibana'; export const BASE_API_URL = '/api/lens';