From 8c3ee176aa0213ac3fef550f9e8a76db19ce01f2 Mon Sep 17 00:00:00 2001 From: liza-mae Date: Tue, 15 Mar 2022 14:12:12 -0600 Subject: [PATCH 01/39] [8.x] Fix security solutions upgrade tests (#127403) * [8.x] Fix security solutions upgrade tests * Fix eslint issues * Fix threshold rule spec * fix eslint * Removed unused fields * Remove references in test * Fix reporter and comments * Rename selector to avoid breaking other refs * Fix rule verification Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../security_solution/cypress/screens/alerts.ts | 1 + .../cypress/screens/alerts_details.ts | 13 ++----------- .../security_solution/cypress/tasks/timeline.ts | 12 ++++++++++++ .../detection_rules/custom_query_rule.spec.ts | 2 ++ .../detection_rules/threshold_rule.spec.ts | 10 +++------- .../threat_hunting/cases/import_case.spec.ts | 6 +++--- .../threat_hunting/timeline/import_timeline.spec.ts | 8 +++++--- .../security_solution_cypress/upgrade_config.ts | 9 ++++++++- 8 files changed, 36 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index acecb0a7f47430..d85755ff5238e7 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -62,6 +62,7 @@ export const OPEN_ALERT_BTN = '[data-test-subj="open-alert-status"]'; export const OPENED_ALERTS_FILTER_BTN = '[data-test-subj="openAlerts"]'; +export const PROCESS_NAME_COLUMN = '[data-test-subj="dataGridHeaderCell-process.name"]'; export const PROCESS_NAME = '[data-test-subj="formatted-field-process.name"]'; export const REASON = '[data-test-subj^=formatted-field][data-test-subj$=reason]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts index 0c1388bcfd6fd7..a6e61d536dd3ba 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts @@ -33,22 +33,13 @@ export const JSON_TEXT = '[data-test-subj="jsonView"]'; export const OVERVIEW_HOST_NAME = '[data-test-subj="eventDetails"] [data-test-subj="host-details-button"]'; -export const OVERVIEW_RISK_SCORE = - '[data-test-subj="eventDetails"] [data-test-subj^=formatted][data-test-subj$=risk_score]'; - -export const OVERVIEW_RULE = - '[data-test-subj="eventDetails"] [data-test-subj^=formatted][data-test-subj$=rule\\.name]'; - -export const OVERVIEW_SEVERITY = - '[data-test-subj="eventDetails"] [data-test-subj^=formatted][data-test-subj$=rule\\.severity]'; - -export const OVERVIEW_STATUS = '[data-test-subj="eventDetails"] [data-test-subj$=status]'; +export const OVERVIEW_SEVERITY = '[data-test-subj="eventDetails"] [data-test-subj=severity]'; export const OVERVIEW_THRESHOLD_COUNT = '[data-test-subj="eventDetails"] [data-test-subj^=formatted][data-test-subj$=threshold_result\\.count]'; export const OVERVIEW_THRESHOLD_VALUE = - '[data-test-subj="eventDetails"] [data-test-subj$=threshold_result\\.terms]'; + '[data-test-subj="eventDetails"] [data-test-subj^=formatted][data-test-subj$=threshold_result\\.terms\\.field]'; export const SUMMARY_VIEW = '[data-test-subj="summary-view"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 24eb2e325d32c2..c4da40e13d4b6f 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -397,3 +397,15 @@ export const expandEventAction = () => { }); cy.get(TIMELINE_COLLAPSED_ITEMS_BTN).click(); }; + +export const setKibanaTimezoneToUTC = () => + cy + .request({ + method: 'POST', + url: 'api/kibana/settings', + body: { changes: { 'dateFormat:tz': 'UTC' } }, + headers: { 'kbn-xsrf': 'set-kibana-timezone-utc' }, + }) + .then(() => { + cy.reload(); + }); diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/custom_query_rule.spec.ts index 55db62d7bf766c..3d17d6734a65b6 100644 --- a/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/custom_query_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/custom_query_rule.spec.ts @@ -8,6 +8,7 @@ import semver from 'semver'; import { DESTINATION_IP, HOST_NAME, + PROCESS_NAME_COLUMN, PROCESS_NAME, REASON, RISK_SCORE, @@ -116,6 +117,7 @@ describe('After an upgrade, the custom query rule', () => { cy.get(REASON).should('have.text', expectedReason).type('{rightarrow}'); cy.get(HOST_NAME).should('have.text', alert.hostName); cy.get(USER_NAME).should('have.text', alert.username); + cy.get(PROCESS_NAME_COLUMN).eq(0).scrollIntoView(); cy.get(PROCESS_NAME).should('have.text', alert.processName); cy.get(SOURCE_IP).should('have.text', alert.sourceIp); cy.get(DESTINATION_IP).should('have.text', alert.destinationIp); diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts index eadee6a7ac9dfa..059f60d06de5c3 100644 --- a/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts @@ -36,10 +36,7 @@ import { loginAndWaitForPage } from '../../../tasks/login'; import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; import { OVERVIEW_HOST_NAME, - OVERVIEW_RISK_SCORE, - OVERVIEW_RULE, OVERVIEW_SEVERITY, - OVERVIEW_STATUS, OVERVIEW_THRESHOLD_COUNT, OVERVIEW_THRESHOLD_VALUE, SUMMARY_VIEW, @@ -49,7 +46,7 @@ const EXPECTED_NUMBER_OF_ALERTS = '1'; const alert = { rule: 'Threshold rule', - severity: 'medium', + severity: 'Medium', riskScore: '17', reason: 'event created medium alert Threshold rule.', hostName: 'security-solution.local', @@ -123,10 +120,9 @@ describe('After an upgrade, the threshold rule', () => { it('Displays the Overview alert details in the alert flyout', () => { expandFirstAlert(); - cy.get(OVERVIEW_STATUS).should('have.text', 'open'); - cy.get(OVERVIEW_RULE).should('have.text', alert.rule); + // TODO: Add verification of OVERVIEW_STATUS, OVERVIEW_RULE, + // OVERVIEW_RISK_CODE - need data-test-subj attributes cy.get(OVERVIEW_SEVERITY).should('have.text', alert.severity); - cy.get(OVERVIEW_RISK_SCORE).should('have.text', alert.riskScore); cy.get(OVERVIEW_HOST_NAME).should('have.text', alert.hostName); cy.get(OVERVIEW_THRESHOLD_COUNT).should('have.text', alert.thresholdCount); cy.get(OVERVIEW_THRESHOLD_VALUE).should('have.text', alert.hostName); diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts index e97cebeff00b59..4f54591cd27aa7 100644 --- a/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts @@ -46,7 +46,7 @@ const importedCase = { reporter: 'glo@test.co', tags: 'export case', numberOfAlerts: '2', - numberOfComments: '4', + numberOfComments: '2', description: "This is the description of the 7.16 case that I'm going to import in future versions.", timeline: 'This is just a timeline', @@ -59,7 +59,7 @@ const updateStatusRegex = new RegExp( `\\S${importedCase.user}marked case as${importedCase.status}\\S*\\s?(\\S*)?\\s?(\\S*)?` ); const alertUpdateRegex = new RegExp( - `\\S${importedCase.user}added an alert from ${importedCase.ruleName}\\S*\\s?(\\S*)?\\s?(\\S*)?` + `\\S${importedCase.user}added an alert from Unknown\\S*\\s?(\\S*)?\\s?(\\S*)?` ); const incidentManagementSystemRegex = new RegExp( `\\S${importedCase.participants[0]}selected ${importedCase.connector} as incident management system\\S*\\s?(\\S*)?\\s?(\\S*)?` @@ -110,7 +110,7 @@ describe('Import case after upgrade', () => { it('Displays the correct case details on the cases page', () => { cy.get(ALL_CASES_NAME).should('have.text', importedCase.title); - cy.get(ALL_CASES_REPORTER).should('have.text', importedCase.reporter); + cy.get(ALL_CASES_REPORTER).should('have.text', importedCase.user); cy.get(ALL_CASES_NUMBER_OF_ALERTS).should('have.text', importedCase.numberOfAlerts); cy.get(ALL_CASES_COMMENTS_COUNT).should('have.text', importedCase.numberOfComments); cy.get(ALL_CASES_NOT_PUSHED).should('be.visible'); diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/timeline/import_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/timeline/import_timeline.spec.ts index c842c96e700e71..3f6101bc24e456 100644 --- a/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/timeline/import_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/timeline/import_timeline.spec.ts @@ -43,6 +43,7 @@ import { deleteTimeline, goToCorrelationTab, goToNotesTab, + setKibanaTimezoneToUTC, } from '../../../tasks/timeline'; import { expandNotes, importTimeline, openTimeline } from '../../../tasks/timelines'; @@ -52,8 +53,8 @@ const timeline = '7_15_timeline.ndjson'; const username = 'elastic'; const timelineDetails = { - dateStart: 'Oct 11, 2020 @ 00:00:00.000', - dateEnd: 'Oct 11, 2030 @ 17:13:15.851', + dateStart: 'Oct 10, 2020 @ 22:00:00.000', + dateEnd: 'Oct 11, 2030 @ 15:13:15.851', queryTab: 'Query4', correlationTab: 'Correlation', analyzerTab: 'Analyzer', @@ -72,7 +73,7 @@ const detectionAlert = { }; const event = { - timestamp: 'Nov 4, 2021 @ 11:09:29.438', + timestamp: 'Nov 4, 2021 @ 10:09:29.438', message: '—', eventCategory: 'file', eventAction: 'initial_scan', @@ -86,6 +87,7 @@ describe('Import timeline after upgrade', () => { before(() => { loginAndWaitForPageWithoutDateRange(TIMELINES_URL); importTimeline(timeline); + setKibanaTimezoneToUTC(); }); after(() => { diff --git a/x-pack/test/security_solution_cypress/upgrade_config.ts b/x-pack/test/security_solution_cypress/upgrade_config.ts index 95aa58489851b8..221cf7b30e0615 100644 --- a/x-pack/test/security_solution_cypress/upgrade_config.ts +++ b/x-pack/test/security_solution_cypress/upgrade_config.ts @@ -5,10 +5,17 @@ * 2.0. */ +import { FtrConfigProviderContext } from '@kbn/test'; + import { SecuritySolutionCypressUpgradeCliTestRunner } from './runner'; -export default async function () { +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaCommonTestsConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + return { + ...kibanaCommonTestsConfig.getAll(), testRunner: SecuritySolutionCypressUpgradeCliTestRunner, }; } From 497aca3a362b6a04cc7a16c2469c3440370e5038 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Tue, 15 Mar 2022 21:29:11 +0100 Subject: [PATCH 02/39] [Cases] Do not show the lens action if Visualize feature is not enabled (#127613) * do not show the lens plugins if not capable of visualizing things * check for save and not show * Add missing mock * move logic for lens to the markdown editor * Fix test and types for cases * Fix tests * Add additional validation for dashbaord --- .../common/lib/kibana/__mocks__/index.ts | 4 ++++ .../cases/public/common/lib/kibana/hooks.ts | 9 ++++++++- .../components/markdown_editor/editor.tsx | 2 +- .../components/markdown_editor/use_plugins.ts | 19 ++++++++++++++++--- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts index f6f91dda04f895..d7ce7318f87243 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts @@ -45,3 +45,7 @@ export const useNavigation = jest.fn().mockReturnValue({ getAppUrl: jest.fn(), navigateTo: jest.fn(), }); + +export const useKibanaCapabilities = jest.fn().mockReturnValue({ + visualize: true, +}); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts index bf81e92af92bd7..75cf9bc7d94499 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -167,9 +167,16 @@ export const useNavigation = (appId: string) => { export const useApplicationCapabilities = (): { crud: boolean; read: boolean } => { const capabilities = useKibana().services.application.capabilities; const casesCapabilities = capabilities[FEATURE_ID]; - return { crud: !!casesCapabilities?.crud_cases, read: !!casesCapabilities?.read_cases, }; }; +export const useKibanaCapabilities = (): { visualize?: boolean; dashboard?: boolean } => { + const capabilities = useKibana().services?.application?.capabilities; + + return { + visualize: !!capabilities?.visualize?.save, + dashboard: !!capabilities?.dashboard?.show && !!capabilities?.dashboard?.createNew, + }; +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx index f04444b57de687..a3de9072f0ea5e 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx @@ -92,6 +92,6 @@ const MarkdownEditorComponent = forwardRef { const kibanaConfig = KibanaServices.getConfig(); const timelinePlugins = useTimelineContext()?.editor_plugins; + const appCapabilities = useKibanaCapabilities(); return useMemo(() => { const uiPlugins = getDefaultEuiMarkdownUiPlugins(); @@ -36,7 +37,13 @@ export const usePlugins = (disabledPlugins?: string[]) => { processingPlugins[1][1].components.timeline = timelinePlugins.processingPluginRenderer; } - if (kibanaConfig?.markdownPlugins?.lens && !disabledPlugins?.includes(LensPluginId)) { + if ( + kibanaConfig?.markdownPlugins?.lens && + !disabledPlugins?.includes(LensPluginId) && + appCapabilities?.visualize && + // TODO remove this check after the lens plugin fixes this bug + appCapabilities.dashboard + ) { uiPlugins.push(lensMarkdownPlugin.plugin); } @@ -49,5 +56,11 @@ export const usePlugins = (disabledPlugins?: string[]) => { parsingPlugins, processingPlugins, }; - }, [disabledPlugins, kibanaConfig?.markdownPlugins?.lens, timelinePlugins]); + }, [ + appCapabilities?.dashboard, + appCapabilities?.visualize, + disabledPlugins, + kibanaConfig?.markdownPlugins?.lens, + timelinePlugins, + ]); }; From b8a03f980634e1ed00cdabd1bd211e611372bb75 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 15 Mar 2022 15:43:29 -0500 Subject: [PATCH 03/39] [ci/es_snapshots] Build cloud image (#127154) * [ci/es_snapshots] Build cloud image * publish docker image * fix cloud manifest * simplify archive name * reference kibana-ci container registry instead of uploading image to bucket * add cloud-docker assemble --- .buildkite/scripts/steps/es_snapshots/build.sh | 18 +++++++++++++++++- .../steps/es_snapshots/create_manifest.js | 13 +++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.buildkite/scripts/steps/es_snapshots/build.sh b/.buildkite/scripts/steps/es_snapshots/build.sh index c11f0418364139..cdc1750e59bfc9 100755 --- a/.buildkite/scripts/steps/es_snapshots/build.sh +++ b/.buildkite/scripts/steps/es_snapshots/build.sh @@ -69,6 +69,7 @@ echo "--- Build Elasticsearch" :distribution:archives:darwin-aarch64-tar:assemble \ :distribution:archives:darwin-tar:assemble \ :distribution:docker:docker-export:assemble \ + :distribution:docker:cloud-docker-export:assemble \ :distribution:archives:linux-aarch64-tar:assemble \ :distribution:archives:linux-tar:assemble \ :distribution:archives:windows-zip:assemble \ @@ -79,11 +80,26 @@ find distribution -type f \( -name 'elasticsearch-*-*-*-*.tar.gz' -o -name 'elas ls -alh "$destination" -echo "--- Create docker image archives" +echo "--- Create docker default image archives" docker images "docker.elastic.co/elasticsearch/elasticsearch" docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}}" | xargs -n1 echo 'docker save docker.elastic.co/elasticsearch/elasticsearch:${0} | gzip > ../es-build/elasticsearch-${0}-docker-image.tar.gz' docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}}" | xargs -n1 bash -c 'docker save docker.elastic.co/elasticsearch/elasticsearch:${0} | gzip > ../es-build/elasticsearch-${0}-docker-image.tar.gz' +echo "--- Create kibana-ci docker cloud image archives" +ES_CLOUD_ID=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.ID}}") +ES_CLOUD_VERSION=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.Tag}}") +KIBANA_ES_CLOUD_VERSION="$ES_CLOUD_VERSION-$ELASTICSEARCH_GIT_COMMIT" +KIBANA_ES_CLOUD_IMAGE="docker.elastic.co/kibana-ci/elasticsearch-cloud:$KIBANA_ES_CLOUD_VERSION" + +docker tag "$ES_CLOUD_ID" "$KIBANA_ES_CLOUD_IMAGE" + +echo "$KIBANA_DOCKER_PASSWORD" | docker login -u "$KIBANA_DOCKER_USERNAME" --password-stdin docker.elastic.co +trap 'docker logout docker.elastic.co' EXIT +docker image push "$KIBANA_ES_CLOUD_IMAGE" + +export ELASTICSEARCH_CLOUD_IMAGE="$KIBANA_ES_CLOUD_IMAGE" +export ELASTICSEARCH_CLOUD_IMAGE_CHECKSUM="$(docker images "$KIBANA_ES_CLOUD_IMAGE" --format "{{.Digest}}")" + echo "--- Create checksums for snapshot files" cd "$destination" find ./* -exec bash -c "shasum -a 512 {} > {}.sha512" \; diff --git a/.buildkite/scripts/steps/es_snapshots/create_manifest.js b/.buildkite/scripts/steps/es_snapshots/create_manifest.js index cb4ea29a9c534a..9357cd72fff06b 100644 --- a/.buildkite/scripts/steps/es_snapshots/create_manifest.js +++ b/.buildkite/scripts/steps/es_snapshots/create_manifest.js @@ -16,6 +16,8 @@ const { BASE_BUCKET_DAILY } = require('./bucket_config.js'); const destination = process.argv[2] || __dirname + '/test'; const ES_BRANCH = process.env.ELASTICSEARCH_BRANCH; + const ES_CLOUD_IMAGE = process.env.ELASTICSEARCH_CLOUD_IMAGE; + const ES_CLOUD_IMAGE_CHECKSUM = process.env.ELASTICSEARCH_CLOUD_IMAGE_CHECKSUM; const GIT_COMMIT = process.env.ELASTICSEARCH_GIT_COMMIT; const GIT_COMMIT_SHORT = process.env.ELASTICSEARCH_GIT_COMMIT_SHORT; @@ -59,6 +61,17 @@ const { BASE_BUCKET_DAILY } = require('./bucket_config.js'); }; }); + if (ES_CLOUD_IMAGE && ES_CLOUD_IMAGE_CHECKSUM) { + manifestEntries.push({ + checksum: ES_CLOUD_IMAGE_CHECKSUM, + url: ES_CLOUD_IMAGE, + version: VERSION, + platform: 'docker', + architecture: 'image', + license: 'default', + }); + } + const manifest = { id: SNAPSHOT_ID, bucket: `${BASE_BUCKET_DAILY}/${DESTINATION}`.toString(), From 50780620c227d07392e7f1890d36e743de008b36 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Tue, 15 Mar 2022 17:13:13 -0400 Subject: [PATCH 04/39] [Security Solution][Endpoint] Add Response Actions console feature flag and top header menu item (#127667) * Add experimental feature: responseActionsConsoleEnabled * Add header button for endpoint consoles --- .../common/experimental_features.ts | 5 ++ .../public/app/home/global_header/index.tsx | 5 ++ ...soles_popover_header_section_item.test.tsx | 63 +++++++++++++++ .../consoles_popover_header_section_item.tsx | 77 +++++++++++++++++++ .../index.ts | 8 ++ 5 files changed, 158 insertions(+) create mode 100644 x-pack/plugins/security_solution/public/common/components/consoles_popover_header_section_item/consoles_popover_header_section_item.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/consoles_popover_header_section_item/consoles_popover_header_section_item.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/consoles_popover_header_section_item/index.ts diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 0ae0dda273958f..ebce7b5b2114e8 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -33,6 +33,11 @@ export const allowedExperimentalValues = Object.freeze({ * @see test/detection_engine_api_integration/security_and_spaces/tests/telemetry/README.md */ previewTelemetryUrlEnabled: false, + + /** + * Enables the Endpoint response actions console in various areas of the app + */ + responseActionsConsoleEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx index 35c204deb6b658..1abda9ca59354d 100644 --- a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx @@ -27,6 +27,7 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { getScopeFromPath, showSourcererByPath } from '../../../common/containers/sourcerer'; +import { ConsolesPopoverHeaderSectionItem } from '../../../common/components/consoles_popover_header_section_item'; const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.buttonAddData', { defaultMessage: 'Add integrations', @@ -72,11 +73,15 @@ export const GlobalHeader = React.memo( return ( + {/* The consoles Popover may or may not be shown, depending on the user's authz */} + + {isDetectionsPath(pathname) && ( )} + { + let render: () => ReturnType; + let renderResult: ReturnType; + let setExperimentalFlag: AppContextTestRender['setExperimentalFlag']; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + setExperimentalFlag = mockedContext.setExperimentalFlag; + setExperimentalFlag({ responseActionsConsoleEnabled: true }); + render = () => { + return (renderResult = mockedContext.render()); + }; + }); + + afterEach(() => { + userUserPrivilegesMock.mockReturnValue({ + ...userUserPrivilegesMock(), + endpointPrivileges: getEndpointPrivilegesInitialStateMock(), + }); + }); + + it('should show menu item if feature flag is true and user has authz to endpoint management', () => { + render(); + + expect(renderResult.getByTestId('endpointConsoles')).toBeTruthy(); + }); + + it('should hide the menu item if feature flag is false', () => { + setExperimentalFlag({ responseActionsConsoleEnabled: false }); + render(); + + expect(renderResult.queryByTestId('endpointConsoles')).toBeNull(); + }); + + it('should hide menu item if user does not have authz to endpoint management', () => { + userUserPrivilegesMock.mockReturnValue({ + ...userUserPrivilegesMock(), + endpointPrivileges: getEndpointPrivilegesInitialStateMock({ + canAccessEndpointManagement: false, + }), + }); + render(); + + expect(renderResult.queryByTestId('endpointConsoles')).toBeNull(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/consoles_popover_header_section_item/consoles_popover_header_section_item.tsx b/x-pack/plugins/security_solution/public/common/components/consoles_popover_header_section_item/consoles_popover_header_section_item.tsx new file mode 100644 index 00000000000000..4cf4617789cf67 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/consoles_popover_header_section_item/consoles_popover_header_section_item.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useState, useCallback, useMemo } from 'react'; +import { EuiHeaderSectionItem, EuiHeaderSectionItemButton, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useUserPrivileges } from '../user_privileges'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; + +const LABELS = Object.freeze({ + buttonLabel: i18n.translate('xpack.securitySolution.consolesPopoverHeaderItem.buttonLabel', { + defaultMessage: 'Endpoint consoles', + }), +}); + +const ConsolesPopover = memo(() => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const handlePopoverToggle = useCallback(() => { + setIsPopoverOpen((prevState) => !prevState); + }, []); + + const handlePopoverClose = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const buttonTextProps = useMemo(() => { + return { style: { fontSize: '1rem' } }; + }, []); + + return ( + + + {LABELS.buttonLabel} + + } + isOpen={isPopoverOpen} + closePopover={handlePopoverClose} + repositionOnScroll + > + { + 'TODO: Currently open consoles and the ability to start a new console will be shown here soon' + } + + + ); +}); +ConsolesPopover.displayName = 'ConsolesPopover'; + +export const ConsolesPopoverHeaderSectionItem = memo((props) => { + const canAccessEndpointManagement = + useUserPrivileges().endpointPrivileges.canAccessEndpointManagement; + const isExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled( + 'responseActionsConsoleEnabled' + ); + + return canAccessEndpointManagement && isExperimentalFeatureEnabled ? : null; +}); +ConsolesPopoverHeaderSectionItem.displayName = 'ConsolesPopoverHeaderSectionItem'; diff --git a/x-pack/plugins/security_solution/public/common/components/consoles_popover_header_section_item/index.ts b/x-pack/plugins/security_solution/public/common/components/consoles_popover_header_section_item/index.ts new file mode 100644 index 00000000000000..6eb2c281c87432 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/consoles_popover_header_section_item/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ConsolesPopoverHeaderSectionItem } from './consoles_popover_header_section_item'; From 7340869e00cb4e3562cb03e1e82688f1a24c507b Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Tue, 15 Mar 2022 17:32:04 -0400 Subject: [PATCH 05/39] A11y tests for tags and tags management page (#127657) --- x-pack/test/accessibility/apps/tags.ts | 115 ++++++++++++++++++ x-pack/test/accessibility/config.ts | 1 + .../page_objects/tag_management_page.ts | 20 +++ 3 files changed, 136 insertions(+) create mode 100644 x-pack/test/accessibility/apps/tags.ts diff --git a/x-pack/test/accessibility/apps/tags.ts b/x-pack/test/accessibility/apps/tags.ts new file mode 100644 index 00000000000000..8174c8fa8c06b4 --- /dev/null +++ b/x-pack/test/accessibility/apps/tags.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// a11y tests for spaces, space selection and space creation and feature controls + +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['common', 'settings', 'header', 'home', 'tagManagement']); + const a11y = getService('a11y'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const toasts = getService('toasts'); + + describe('Kibana tags page meets a11y validations', () => { + before(async () => { + await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { + useActualUrl: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.home.addSampleDataSet('flights'); + await PageObjects.settings.navigateTo(); + await testSubjects.click('tags'); + }); + + after(async () => { + await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { + useActualUrl: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.home.removeSampleDataSet('flights'); + }); + + it('tags main page meets a11y validations', async () => { + await a11y.testAppSnapshot(); + }); + + it('create tag panel meets a11y validations', async () => { + await testSubjects.click('createTagButton'); + await a11y.testAppSnapshot(); + }); + + it('tag listing page meets a11y validations', async () => { + await PageObjects.tagManagement.tagModal.fillForm( + { + name: 'a11yTag', + color: '#fc03db', + description: 'a11y test tag', + }, + { + submit: true, + } + ); + await a11y.testAppSnapshot(); + }); + + it('edit tag panel meets a11y validations', async () => { + const tagName = 'a11yTag'; + await PageObjects.tagManagement.tagModal.openEdit(tagName); + await a11y.testAppSnapshot(); + }); + + it('tag actions panel meets a11y requirements', async () => { + await testSubjects.click('createModalCancelButton'); + + await testSubjects.click('euiCollapsedItemActionsButton'); + await a11y.testAppSnapshot(); + }); + + it('tag assignment panel meets a11y requirements', async () => { + await testSubjects.click('euiCollapsedItemActionsButton'); + const actionOnTag = 'assign'; + await PageObjects.tagManagement.clickActionItem(actionOnTag); + await a11y.testAppSnapshot(); + }); + + it('tag management page with connections column populated meets a11y requirements', async () => { + await testSubjects.click('assignFlyout-selectAllButton'); + + await testSubjects.click('assignFlyoutConfirmButton'); + await toasts.dismissAllToasts(); + + await retry.try(async () => { + await a11y.testAppSnapshot(); + }); + }); + + it('bulk actions panel meets a11y requirements', async () => { + await testSubjects.click('createTagButton'); + await PageObjects.tagManagement.tagModal.fillForm( + { + name: 'a11yTag2', + color: '#fc04db', + description: 'a11y test tag2', + }, + { + submit: true, + } + ); + await testSubjects.click('checkboxSelectAll'); + await PageObjects.tagManagement.openActionMenu(); + await a11y.testAppSnapshot(); + }); + + it('Delete tags panel meets a11y requirements', async () => { + await testSubjects.click('actionBar-button-delete'); + await a11y.testAppSnapshot(); + await testSubjects.click('confirmModalConfirmButton'); + }); + }); +} diff --git a/x-pack/test/accessibility/config.ts b/x-pack/test/accessibility/config.ts index e970f6d07b90a4..abb9340c6408a8 100644 --- a/x-pack/test/accessibility/config.ts +++ b/x-pack/test/accessibility/config.ts @@ -41,6 +41,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./apps/reporting'), require.resolve('./apps/enterprise_search'), require.resolve('./apps/license_management'), + require.resolve('./apps/tags'), ], pageObjects, diff --git a/x-pack/test/functional/page_objects/tag_management_page.ts b/x-pack/test/functional/page_objects/tag_management_page.ts index 55e587c3c16f33..e343cec1c8c291 100644 --- a/x-pack/test/functional/page_objects/tag_management_page.ts +++ b/x-pack/test/functional/page_objects/tag_management_page.ts @@ -326,6 +326,26 @@ export class TagManagementPageObject extends FtrService { } } + async clickActionItem(action: string) { + const rows = await this.testSubjects.findAll('tagsTableRow'); + const firstRow = rows[0]; + // if there is more than 2 actions, they are wrapped in a popover that opens from a new action. + const menuActionPresent = await this.testSubjects.descendantExists( + 'euiCollapsedItemActionsButton', + firstRow + ); + if (menuActionPresent) { + const actionButton = await this.testSubjects.findDescendant( + 'euiCollapsedItemActionsButton', + firstRow + ); + await actionButton.click(); + await this.testSubjects.click(`tagsTableAction-${action}`); + } else { + await this.testSubjects.click(`tagsTableAction-${action}`); + } + } + /** * Return the (table ordered) name of the tags currently displayed in the table. */ From 692ca00ef03eb0aca1c2c97be1242ba2a4375aaf Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 15 Mar 2022 21:52:35 +0000 Subject: [PATCH 06/39] [Security Solution] Add visualization actions (#126507) * add chart configs * init hosts chart actions * init network chart actions * add to new case * clean up * clean up * clean up configs * rename configs * rename histogram actions to viz actions component * fix up * add vizType * cypress hosts inspect * add viz actions cypress tests * fix type * stat_items unit test * fix unit tests for alerts by category * fix unit tests * unit tests * unit tests * rename vizType from store to inspectedVizType * move out i18n * unit test * add index filter * clean up configs * unit tests * fix typo * rm unused props * apply cases flyout and modal * rm unused definition * fix typo * rm vizType in reducer * onCloseInspect callback * move viz action component out of header section * rm hard coded dataViewId in configs * update icon and wording * fix unit tests * useRouteSpy * showInspectButton * add aria label * rm type casting * unit test * rm id from filters * use mockCasesContract * add unit tests * styling * update mock * rm visualization actions cypress tests * clean up data-test-subj * disabled inspect button in matrix histogram by default * styling * viz actions only available on hosts / network page * rm kpi * unit tests * unit tests * unit tests * unit tests * kibana dependency * rename * add readme Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../cypress/screens/inspect.ts | 28 -- x-pack/plugins/security_solution/kibana.json | 2 +- .../alerts_viewer/histogram_configs.ts | 2 + .../common/components/charts/areachart.tsx | 49 +++- .../common/components/charts/barchart.tsx | 60 +++-- .../common/components/charts/common.tsx | 4 + .../components/header_section/index.test.tsx | 26 +- .../components/header_section/index.tsx | 17 +- .../hover_actions/actions/show_top_n.test.tsx | 23 ++ .../common/components/inspect/index.tsx | 112 ++------ .../common/components/inspect/use_inspect.tsx | 111 ++++++++ .../matrix_histogram/index.test.tsx | 152 ++++++++++- .../components/matrix_histogram/index.tsx | 53 +++- .../components/matrix_histogram/types.ts | 3 + .../common/components/stat_items/index.tsx | 140 ++++++---- .../common/components/top_n/index.test.tsx | 4 +- .../common/components/top_n/top_n.test.tsx | 3 + .../visualization_actions/index.test.tsx | 241 ++++++++++++++++++ .../visualization_actions/index.tsx | 240 +++++++++++++++++ .../lens_attributes/common/external_alert.ts | 152 +++++++++++ .../lens_attributes/hosts/authentication.ts | 191 ++++++++++++++ .../lens_attributes/hosts/events.ts | 124 +++++++++ .../lens_attributes/hosts/kpi_host_area.ts | 84 ++++++ .../lens_attributes/hosts/kpi_host_metric.ts | 56 ++++ .../hosts/kpi_unique_ips_area.ts | 129 ++++++++++ .../hosts/kpi_unique_ips_bar.ts | 127 +++++++++ .../kpi_unique_ips_destination_metric.ts | 56 ++++ .../hosts/kpi_unique_ips_source_metric.ts | 56 ++++ .../kpi_user_authentication_metric_failure.ts | 88 +++++++ .../hosts/kpi_user_authentications_area.ts | 200 +++++++++++++++ .../hosts/kpi_user_authentications_bar.ts | 201 +++++++++++++++ ...kpi_user_authentications_metric_success.ts | 89 +++++++ .../network/dns_top_domains.ts | 161 ++++++++++++ .../network/kpi_dns_queries.ts | 105 ++++++++ .../network/kpi_network_events.ts | 108 ++++++++ .../network/kpi_tls_handshakes.ts | 129 ++++++++++ .../network/kpi_unique_flow_ids.ts | 92 +++++++ .../network/kpi_unique_private_ips_area.ts | 174 +++++++++++++ .../network/kpi_unique_private_ips_bar.ts | 191 ++++++++++++++ ...i_unique_private_ips_destination_metric.ts | 65 +++++ .../kpi_unique_private_ips_source_metric.ts | 64 +++++ .../lens_attributes/readme.md | 19 ++ .../visualization_actions/translations.ts | 82 ++++++ .../components/visualization_actions/types.ts | 27 ++ .../use_add_to_existing_case.test.tsx | 92 +++++++ .../use_add_to_existing_case.tsx | 58 +++++ .../use_add_to_new_case.test.tsx | 86 +++++++ .../use_add_to_new_case.tsx | 61 +++++ .../use_lens_attributes.test.tsx | 157 ++++++++++++ .../use_lens_attributes.tsx | 102 ++++++++ .../components/visualization_actions/utils.ts | 138 ++++++++++ .../public/common/store/inputs/helpers.ts | 9 +- .../kpi_hosts/authentications/index.tsx | 8 + .../components/kpi_hosts/common/index.tsx | 15 +- .../components/kpi_hosts/hosts/index.tsx | 4 + .../kpi_hosts/risky_hosts/translations.ts | 2 +- .../components/kpi_hosts/unique_ips/index.tsx | 8 + .../hosts/pages/details/details_tabs.test.tsx | 26 +- .../public/hosts/pages/hosts.test.tsx | 20 ++ .../public/hosts/pages/hosts.tsx | 8 +- .../navigation/alerts_query_tab_body.tsx | 38 +-- .../authentications_query_tab_body.tsx | 3 + .../navigation/events_query_tab_body.tsx | 2 + .../components/kpi_network/common/index.tsx | 15 +- .../components/kpi_network/dns/index.tsx | 2 + .../network/components/kpi_network/mock.ts | 12 + .../kpi_network/network_events/index.tsx | 2 + .../kpi_network/tls_handshakes/index.tsx | 2 + .../kpi_network/unique_flows/index.tsx | 2 + .../kpi_network/unique_private_ips/index.tsx | 8 + .../navigation/alerts_query_tab_body.tsx | 55 +--- .../pages/navigation/dns_query_tab_body.tsx | 2 + .../public/network/pages/network.test.tsx | 7 + .../public/network/pages/network.tsx | 6 +- .../alerts_by_category/index.test.tsx | 211 ++++++++++++++- .../components/alerts_by_category/index.tsx | 10 +- .../components/event_counts/index.tsx | 10 +- .../components/events_by_dataset/index.tsx | 11 +- .../public/overview/pages/overview.test.tsx | 4 + .../public/users/pages/users_tabs.test.tsx | 20 ++ 80 files changed, 4927 insertions(+), 329 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/inspect/use_inspect.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/external_alert.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/authentication.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/events.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_bar.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_destination_metric.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_source_metric.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentication_metric_failure.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_area.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_bar.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_metric_success.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/dns_top_domains.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_dns_queries.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_network_events.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_area.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_bar.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_destination_metric.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_source_metric.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/readme.md create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/types.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/utils.ts diff --git a/x-pack/plugins/security_solution/cypress/screens/inspect.ts b/x-pack/plugins/security_solution/cypress/screens/inspect.ts index f2b332b1772b20..f0fbf7e6a30890 100644 --- a/x-pack/plugins/security_solution/cypress/screens/inspect.ts +++ b/x-pack/plugins/security_solution/cypress/screens/inspect.ts @@ -16,14 +16,6 @@ export interface InspectButtonMetadata { } export const INSPECT_HOSTS_BUTTONS_IN_SECURITY: InspectButtonMetadata[] = [ - { - id: '[data-test-subj="stat-hosts"]', - title: 'Hosts Stat', - }, - { - id: '[data-test-subj="stat-uniqueIps"]', - title: 'Unique IPs Stat', - }, { id: '[data-test-subj="table-allHosts-loading-false"]', title: 'All Hosts Table', @@ -48,26 +40,6 @@ export const INSPECT_HOSTS_BUTTONS_IN_SECURITY: InspectButtonMetadata[] = [ ]; export const INSPECT_NETWORK_BUTTONS_IN_SECURITY: InspectButtonMetadata[] = [ - { - id: '[data-test-subj="stat-networkEvents"]', - title: 'Network events Stat', - }, - { - id: '[data-test-subj="stat-dnsQueries"]', - title: 'DNS queries Stat', - }, - { - id: '[data-test-subj="stat-uniqueFlowId"]', - title: 'Unique flow IDs Stat', - }, - { - id: '[data-test-subj="stat-tlsHandshakes"]', - title: 'TLS handshakes Stat', - }, - { - id: '[data-test-subj="stat-UniqueIps"]', - title: 'Unique private IPs Stat', - }, { id: '[data-test-subj="table-topNFlowSource-loading-false"]', title: 'Source IPs Table', diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 9f22f229b33c19..ba289e48fd6a20 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -18,6 +18,7 @@ "eventLog", "features", "inspector", + "lens", "licensing", "maps", "ruleRegistry", @@ -35,7 +36,6 @@ "security", "spaces", "usageCollection", - "lens", "lists", "home", "telemetry", diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/histogram_configs.ts b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/histogram_configs.ts index e710dd2c247dbc..f8500651145cb8 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/histogram_configs.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/histogram_configs.ts @@ -8,6 +8,7 @@ import * as i18n from './translations'; import { MatrixHistogramOption, MatrixHistogramConfigs } from '../matrix_histogram/types'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution/matrix_histogram'; +import { getExternalAlertLensAttributes } from '../visualization_actions/lens_attributes/common/external_alert'; export const alertsStackByOptions: MatrixHistogramOption[] = [ { @@ -30,4 +31,5 @@ export const histogramConfigs: MatrixHistogramConfigs = { stackByOptions: alertsStackByOptions, subtitle: undefined, title: i18n.ALERTS_GRAPH_TITLE, + getLensAttributes: getExternalAlertLensAttributes, }; diff --git a/x-pack/plugins/security_solution/public/common/components/charts/areachart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/areachart.tsx index 9a00736c4a605f..313b216eb19ea6 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/areachart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/areachart.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { Axis, AreaSeries, @@ -16,8 +16,10 @@ import { AreaSeriesStyle, RecursivePartial, } from '@elastic/charts'; + import { getOr, get, isNull, isNumber } from 'lodash/fp'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useThrottledResizeObserver } from '../utils'; import { ChartPlaceHolder } from './chart_place_holder'; import { useTimeZone } from '../../lib/kibana'; @@ -29,7 +31,12 @@ import { getChartWidth, WrappedByAutoSizer, useTheme, + Wrapper, } from './common'; +import { VisualizationActions, HISTOGRAM_ACTIONS_BUTTON_CLASS } from '../visualization_actions'; +import { VisualizationActionsProps } from '../visualization_actions/types'; + +import { HoverVisibilityContainer } from '../hover_visibility_container'; // custom series styles: https://ela.st/areachart-styling const getSeriesLineStyle = (): RecursivePartial => { @@ -138,21 +145,47 @@ AreaChartBase.displayName = 'AreaChartBase'; interface AreaChartComponentProps { areaChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; + visualizationActionsOptions?: VisualizationActionsProps; } -export const AreaChartComponent: React.FC = ({ areaChart, configs }) => { +export const AreaChartComponent: React.FC = ({ + areaChart, + configs, + visualizationActionsOptions, +}) => { const { ref: measureRef, width, height } = useThrottledResizeObserver(); const customHeight = get('customHeight', configs); const customWidth = get('customWidth', configs); const chartHeight = getChartHeight(customHeight, height); const chartWidth = getChartWidth(customWidth, width); - return checkIfAnyValidSeriesExist(areaChart) ? ( - - - - ) : ( - + const isValidSeriesExist = useMemo(() => checkIfAnyValidSeriesExist(areaChart), [areaChart]); + + return ( + + + {isValidSeriesExist && areaChart && ( + + + + + + + + )} + {!isValidSeriesExist && ( + + )} + {visualizationActionsOptions != null && ( + + )} + + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx index a2bc3a2c0456aa..deee4f97331089 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx @@ -31,10 +31,14 @@ import { getChartWidth, WrappedByAutoSizer, useTheme, + Wrapper, } from './common'; import { DraggableLegend } from './draggable_legend'; import { LegendItem } from './draggable_legend_item'; import type { ChartData } from './common'; +import { VisualizationActions, HISTOGRAM_ACTIONS_BUTTON_CLASS } from '../visualization_actions'; +import { VisualizationActionsProps } from '../visualization_actions/types'; +import { HoverVisibilityContainer } from '../hover_visibility_container'; const LegendFlexItem = styled(EuiFlexItem)` overview: hidden; @@ -144,6 +148,7 @@ interface BarChartComponentProps { configs?: ChartSeriesConfigs | undefined; stackByField?: string; timelineId?: string; + visualizationActionsOptions?: VisualizationActionsProps; } const NO_LEGEND_DATA: LegendItem[] = []; @@ -153,6 +158,7 @@ export const BarChartComponent: React.FC = ({ configs, stackByField, timelineId, + visualizationActionsOptions, }) => { const { ref: measureRef, width, height } = useThrottledResizeObserver(); const legendItems: LegendItem[] = useMemo( @@ -176,27 +182,39 @@ export const BarChartComponent: React.FC = ({ const customWidth = get('customWidth', configs); const chartHeight = getChartHeight(customHeight, height); const chartWidth = getChartWidth(customWidth, width); - - return checkIfAnyValidSeriesExist(barChart) ? ( - - - - - - - - - - - ) : ( - + const isValidSeriesExist = useMemo(() => checkIfAnyValidSeriesExist(barChart), [barChart]); + + return ( + + + {isValidSeriesExist && barChart && ( + + + + + + + + + + + + )} + {!isValidSeriesExist && ( + + )} + {visualizationActionsOptions != null && ( + + )} + + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/charts/common.tsx b/x-pack/plugins/security_solution/public/common/components/charts/common.tsx index d7bafffec9a8ff..efb03c12183549 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/common.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/common.tsx @@ -136,3 +136,7 @@ export const checkIfAllValuesAreZero = (data: ChartSeriesData[] | null | undefin data.every((series) => { return Array.isArray(series.value) && (series.value as ChartData[]).every(({ y }) => y === 0); }); + +export const Wrapper = styled.div` + position: relative; +`; diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx index 47b6451dd3090c..5ec97ea59bc1de 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx @@ -145,7 +145,31 @@ describe('HeaderSection', () => { expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true); }); - test('it does NOT an inspect button when an `id` is NOT provided', () => { + test('it renders an inspect button when an `id` is provided and `showInspectButton` is true', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true); + }); + + test('it does NOT render an inspect button when `showInspectButton` is false', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false); + }); + + test('it does NOT render an inspect button when an `id` is NOT provided', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx index 5cb1cd1051ec6c..ae07a03ba6407f 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx @@ -10,6 +10,7 @@ import React from 'react'; import styled, { css } from 'styled-components'; import { InspectButton } from '../inspect'; + import { Subtitle } from '../subtitle'; interface HeaderProps { @@ -39,37 +40,39 @@ Header.displayName = 'Header'; export interface HeaderSectionProps extends HeaderProps { children?: React.ReactNode; + growLeftSplit?: boolean; headerFilters?: string | React.ReactNode; height?: number; + hideSubtitle?: boolean; id?: string; + inspectMultiple?: boolean; isInspectDisabled?: boolean; + showInspectButton?: boolean; split?: boolean; stackHeader?: boolean; subtitle?: string | React.ReactNode; title: string | React.ReactNode; titleSize?: EuiTitleSize; tooltip?: string; - growLeftSplit?: boolean; - inspectMultiple?: boolean; - hideSubtitle?: boolean; } const HeaderSectionComponent: React.FC = ({ border, children, + growLeftSplit = true, headerFilters, height, + hideSubtitle = false, id, + inspectMultiple = false, isInspectDisabled, + showInspectButton = true, split, stackHeader, subtitle, title, titleSize = 'm', tooltip, - growLeftSplit = true, - inspectMultiple = false, - hideSubtitle = false, }) => (
= ({ )} - {id && ( + {id && showInspectButton && ( ({ + VisualizationActions: jest.fn(() =>
), +})); + +jest.mock('../../../lib/kibana', () => { + const original = jest.requireActual('../../../lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + cases: { + ui: { + getCasesContext: jest.fn().mockReturnValue(mockCasesContext), + }, + }, + }, + }), + }; +}); + describe('show topN button', () => { const defaultProps = { field: 'signal.rule.name', diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx index defb90b9054f11..134faa0928b93c 100644 --- a/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx @@ -6,17 +6,14 @@ */ import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; -import { omit } from 'lodash/fp'; -import React, { useMemo, useCallback } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import React from 'react'; -import { inputsSelectors, State } from '../../store'; import { InputsModelId } from '../../store/inputs/constants'; -import { inputsActions } from '../../store/inputs'; import { HoverVisibilityContainer } from '../hover_visibility_container'; import { ModalInspectQuery } from './modal'; +import { useInspect } from './use_inspect'; import * as i18n from './translations'; export const BUTTON_CLASS = 'inspectButtonComponent'; @@ -35,84 +32,45 @@ export const InspectButtonContainer: React.FC = ({ ); -interface OwnProps { +interface InspectButtonProps { compact?: boolean; - queryId: string; inputId?: InputsModelId; inspectIndex?: number; isDisabled?: boolean; + multiple?: boolean; onCloseInspect?: () => void; + queryId: string; title: string | React.ReactElement | React.ReactNode; - multiple?: boolean; } -type InspectButtonProps = OwnProps & PropsFromRedux; - const InspectButtonComponent: React.FC = ({ compact = false, inputId = 'global', - inspect, inspectIndex = 0, isDisabled, - isInspected, - loading, multiple = false, // If multiple = true we ignore the inspectIndex and pass all requests and responses to the inspect modal onCloseInspect, queryId = '', - selectedInspectIndex, - setIsInspected, title = '', }) => { - const handleClick = useCallback(() => { - setIsInspected({ - id: queryId, - inputId, - isInspected: true, - selectedInspectIndex: inspectIndex, - }); - }, [setIsInspected, queryId, inputId, inspectIndex]); - - const handleCloseModal = useCallback(() => { - if (onCloseInspect != null) { - onCloseInspect(); - } - setIsInspected({ - id: queryId, - inputId, - isInspected: false, - selectedInspectIndex: inspectIndex, - }); - }, [onCloseInspect, setIsInspected, queryId, inputId, inspectIndex]); - - let request: string | null = null; - let additionalRequests: string[] | null = null; - if (inspect != null && inspect.dsl.length > 0) { - if (multiple) { - [request, ...additionalRequests] = inspect.dsl; - } else { - request = inspect.dsl[inspectIndex]; - } - } - - let response: string | null = null; - let additionalResponses: string[] | null = null; - if (inspect != null && inspect.response.length > 0) { - if (multiple) { - [response, ...additionalResponses] = inspect.response; - } else { - response = inspect.response[inspectIndex]; - } - } - - const isShowingModal = useMemo( - () => !loading && selectedInspectIndex === inspectIndex && isInspected, - [inspectIndex, isInspected, loading, selectedInspectIndex] - ); - - const isButtonDisabled = useMemo( - () => loading || isDisabled || request == null || response == null, - [isDisabled, loading, request, response] - ); + const { + additionalRequests, + additionalResponses, + handleClick, + handleCloseModal, + isButtonDisabled, + isShowingModal, + loading, + request, + response, + } = useInspect({ + inputId, + inspectIndex, + isDisabled, + multiple, + onCloseInspect, + queryId, + }); return ( <> @@ -159,25 +117,5 @@ const InspectButtonComponent: React.FC = ({ ); }; -const makeMapStateToProps = () => { - const getGlobalQuery = inputsSelectors.globalQueryByIdSelector(); - const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector(); - const mapStateToProps = (state: State, { inputId = 'global', queryId }: OwnProps) => { - const props = - inputId === 'global' ? getGlobalQuery(state, queryId) : getTimelineQuery(state, queryId); - // refetch caused unnecessary component rerender and it was even not used - const propsWithoutRefetch = omit('refetch', props); - return propsWithoutRefetch; - }; - return mapStateToProps; -}; - -const mapDispatchToProps = { - setIsInspected: inputsActions.setInspectionParameter, -}; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const InspectButton = connector(React.memo(InspectButtonComponent)); +InspectButtonComponent.displayName = 'InspectButtonComponent'; +export const InspectButton = React.memo(InspectButtonComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/use_inspect.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/use_inspect.tsx new file mode 100644 index 00000000000000..d9f633c7d01e3a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/inspect/use_inspect.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { inputsSelectors } from '../../store'; +import { inputsActions } from '../../store/actions'; +import { InputsModelId } from '../../store/inputs/constants'; + +interface UseInspectModalProps { + inputId?: InputsModelId; + inspectIndex?: number; + isDisabled?: boolean; + multiple?: boolean; + onClick?: () => void; + onCloseInspect?: () => void; + queryId: string; +} + +export const useInspect = ({ + inputId = 'global', + inspectIndex = 0, + isDisabled, + multiple = false, // If multiple = true we ignore the inspectIndex and pass all requests and responses to the inspect modal + onClick, + onCloseInspect, + queryId, +}: UseInspectModalProps) => { + const dispatch = useDispatch(); + + const getGlobalQuery = inputsSelectors.globalQueryByIdSelector(); + const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector(); + const { loading, inspect, selectedInspectIndex, isInspected } = useDeepEqualSelector((state) => + inputId === 'global' ? getGlobalQuery(state, queryId) : getTimelineQuery(state, queryId) + ); + + const handleClick = useCallback(() => { + if (onClick) { + onClick(); + } + dispatch( + inputsActions.setInspectionParameter({ + id: queryId, + inputId, + isInspected: true, + selectedInspectIndex: inspectIndex, + }) + ); + }, [onClick, dispatch, queryId, inputId, inspectIndex]); + + const handleCloseModal = useCallback(() => { + if (onCloseInspect != null) { + onCloseInspect(); + } + dispatch( + inputsActions.setInspectionParameter({ + id: queryId, + inputId, + isInspected: false, + selectedInspectIndex: inspectIndex, + }) + ); + }, [onCloseInspect, dispatch, queryId, inputId, inspectIndex]); + + let request: string | null = null; + let additionalRequests: string[] | null = null; + if (inspect != null && inspect.dsl.length > 0) { + if (multiple) { + [request, ...additionalRequests] = inspect.dsl; + } else { + request = inspect.dsl[inspectIndex]; + } + } + + let response: string | null = null; + let additionalResponses: string[] | null = null; + if (inspect != null && inspect.response.length > 0) { + if (multiple) { + [response, ...additionalResponses] = inspect.response; + } else { + response = inspect.response[inspectIndex]; + } + } + + const isShowingModal = useMemo( + () => !loading && selectedInspectIndex === inspectIndex && isInspected, + [inspectIndex, isInspected, loading, selectedInspectIndex] + ); + + const isButtonDisabled = useMemo( + () => loading || isDisabled || request == null || response == null || queryId == null, + [isDisabled, loading, queryId, request, response] + ); + + return { + additionalRequests, + additionalResponses, + handleClick, + handleCloseModal, + isButtonDisabled, + isShowingModal, + loading, + request, + response, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx index bcd855fb731871..aee49bd1b00ae9 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx @@ -13,17 +13,14 @@ import { useMatrixHistogramCombined } from '../../containers/matrix_histogram'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { TestProviders } from '../../mock'; import { mockRuntimeMappings } from '../../containers/source/mock'; - +import { dnsTopDomainsLensAttributes } from '../visualization_actions/lens_attributes/network/dns_top_domains'; +import { useRouteSpy } from '../../utils/route/use_route_spy'; jest.mock('../../lib/kibana'); jest.mock('./matrix_loader', () => ({ MatrixLoader: () =>
, })); -jest.mock('../header_section', () => ({ - HeaderSection: () =>
, -})); - jest.mock('../charts/barchart', () => ({ BarChart: () =>
, })); @@ -32,11 +29,31 @@ jest.mock('../../containers/matrix_histogram', () => ({ useMatrixHistogramCombined: jest.fn(), })); +jest.mock('../visualization_actions', () => ({ + VisualizationActions: jest.fn(({ className }: { className: string }) => ( +
+ )), +})); + +jest.mock('../inspect', () => ({ + InspectButton: jest.fn(() =>
), +})); + jest.mock('../../components/matrix_histogram/utils', () => ({ getBarchartConfigs: jest.fn(), getCustomChartData: jest.fn().mockReturnValue(true), })); +jest.mock('../../utils/route/use_route_spy', () => ({ + useRouteSpy: jest.fn().mockReturnValue([ + { + detailName: 'mockHost', + pageName: 'hosts', + tabName: 'externalAlerts', + }, + ]), +})); + describe('Matrix Histogram Component', () => { let wrapper: ReactWrapper; @@ -145,4 +162,129 @@ describe('Matrix Histogram Component', () => { expect(wrapper.find('EuiSelect').exists()).toBe(false); }); }); + + describe('Inspect button', () => { + test("it doesn't render Inspect button by default on Host page", () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: 'mockHost', + pageName: 'hosts', + tabName: 'externalAlerts', + }, + ]); + + const testProps = { + ...mockMatrixOverTimeHistogramProps, + lensAttributes: dnsTopDomainsLensAttributes, + }; + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="mock-inspect"]').exists()).toBe(false); + }); + + test("it doesn't render Inspect button by default on Network page", () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: undefined, + pageName: 'network', + tabName: 'external-alerts', + }, + ]); + + const testProps = { + ...mockMatrixOverTimeHistogramProps, + lensAttributes: dnsTopDomainsLensAttributes, + }; + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="mock-inspect"]').exists()).toBe(false); + }); + + test('it render Inspect button by default on other pages', () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: undefined, + pageName: 'overview', + tabName: undefined, + }, + ]); + + const testProps = { + ...mockMatrixOverTimeHistogramProps, + lensAttributes: dnsTopDomainsLensAttributes, + }; + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="mock-inspect"]').exists()).toBe(true); + }); + }); + + describe('VisualizationActions', () => { + test('it renders VisualizationActions on Host page if lensAttributes is provided', () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: 'mockHost', + pageName: 'hosts', + tabName: 'externalAlerts', + }, + ]); + + const testProps = { + ...mockMatrixOverTimeHistogramProps, + lensAttributes: dnsTopDomainsLensAttributes, + }; + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="mock-viz-actions"]').prop('className')).toEqual( + 'histogram-viz-actions' + ); + }); + + test('it renders VisualizationActions on Network page if lensAttributes is provided', () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: undefined, + pageName: 'network', + tabName: 'external-alerts', + }, + ]); + + const testProps = { + ...mockMatrixOverTimeHistogramProps, + lensAttributes: dnsTopDomainsLensAttributes, + }; + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="mock-viz-actions"]').prop('className')).toEqual( + 'histogram-viz-actions' + ); + }); + + test("it doesn't renders VisualizationActions except Host / Network pages", () => { + const testProps = { + ...mockMatrixOverTimeHistogramProps, + lensAttributes: dnsTopDomainsLensAttributes, + }; + + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: undefined, + pageName: 'overview', + tabName: undefined, + }, + ]); + + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(false); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index cde30fb81176f2..dbf525f8e14cb1 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -19,7 +19,6 @@ import { Panel } from '../panel'; import { getBarchartConfigs, getCustomChartData } from './utils'; import { useMatrixHistogramCombined } from '../../containers/matrix_histogram'; import { MatrixHistogramProps, MatrixHistogramOption, MatrixHistogramQueryProps } from './types'; -import { InspectButtonContainer } from '../inspect'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { MatrixHistogramMappingTypes, @@ -29,20 +28,29 @@ import { import { GlobalTimeArgs } from '../../containers/use_global_time'; import { setAbsoluteRangeDatePicker } from '../../store/inputs/actions'; import { InputsModelId } from '../../store/inputs/constants'; +import { HoverVisibilityContainer } from '../hover_visibility_container'; +import { HISTOGRAM_ACTIONS_BUTTON_CLASS, VisualizationActions } from '../visualization_actions'; +import { GetLensAttributes, LensAttributes } from '../visualization_actions/types'; +import { useKibana, useGetUserCasesPermissions } from '../../lib/kibana'; +import { APP_ID, SecurityPageName } from '../../../../common/constants'; +import { useRouteSpy } from '../../utils/route/use_route_spy'; export type MatrixHistogramComponentProps = MatrixHistogramProps & Omit & { defaultStackByOption: MatrixHistogramOption; errorMessage: string; + getLensAttributes?: GetLensAttributes; headerChildren?: React.ReactNode; hideHistogramIfEmpty?: boolean; histogramType: MatrixHistogramType; id: string; legendPosition?: Position; + lensAttributes?: LensAttributes; mapping?: MatrixHistogramMappingTypes; onError?: () => void; showSpacer?: boolean; setQuery: GlobalTimeArgs['setQuery']; + showInspectButton?: boolean; setAbsoluteRangeDatePickerTarget?: InputsModelId; showLegend?: boolean; stackByOptions: MatrixHistogramOption[]; @@ -53,10 +61,6 @@ export type MatrixHistogramComponentProps = MatrixHistogramProps & const DEFAULT_PANEL_HEIGHT = 300; -const HeaderChildrenFlexItem = styled(EuiFlexItem)` - margin-left: 24px; -`; - const HistogramPanel = styled(Panel)<{ height?: number }>` display: flex; flex-direction: column; @@ -70,6 +74,7 @@ export const MatrixHistogramComponent: React.FC = endDate, errorMessage, filterQuery, + getLensAttributes, headerChildren, histogramType, hideHistogramIfEmpty = false, @@ -78,12 +83,14 @@ export const MatrixHistogramComponent: React.FC = runtimeMappings, isPtrIncluded, legendPosition, + lensAttributes, mapping, onError, paddingSize = 'm', panelHeight = DEFAULT_PANEL_HEIGHT, setAbsoluteRangeDatePickerTarget = 'global', setQuery, + showInspectButton = false, showLegend, showSpacer = true, stackByOptions, @@ -96,6 +103,11 @@ export const MatrixHistogramComponent: React.FC = skip, }) => { const dispatch = useDispatch(); + const { cases } = useKibana().services; + const CasesContext = cases.ui.getCasesContext(); + const userPermissions = useGetUserCasesPermissions(); + const userCanCrud = userPermissions?.crud ?? false; + const handleBrushEnd = useCallback( ({ x }) => { if (!x) { @@ -154,6 +166,10 @@ export const MatrixHistogramComponent: React.FC = const [loading, { data, inspect, totalCount, refetch }] = useMatrixHistogramCombined(matrixHistogramRequest); + const [{ pageName }] = useRouteSpy(); + + const onHostOrNetworkPage = + pageName === SecurityPageName.hosts || pageName === SecurityPageName.network; const titleWithStackByField = useMemo( () => (title != null && typeof title === 'function' ? title(selectedStackByOption) : title), @@ -196,13 +212,17 @@ export const MatrixHistogramComponent: React.FC = setIsInitialLoading, ]); + const timerange = useMemo(() => ({ from: startDate, to: endDate }), [startDate, endDate]); if (hideHistogram) { return null; } return ( <> - + = titleSize={titleSize} subtitle={subtitleWithCounts} inspectMultiple + showInspectButton={showInspectButton || !onHostOrNetworkPage} isInspectDisabled={filterQuery === undefined} > + {onHostOrNetworkPage && (getLensAttributes || lensAttributes) && timerange && ( + + + + + + )} {stackByOptions.length > 1 && ( = /> )} - {headerChildren} + {headerChildren} @@ -251,7 +288,7 @@ export const MatrixHistogramComponent: React.FC = /> )} - + {showSpacer && } ); diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts index 47253d36900bd8..d73a543de50fd6 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts @@ -17,6 +17,7 @@ import { UpdateDateRange } from '../charts/common'; import { GlobalTimeArgs } from '../../containers/use_global_time'; import { DocValueFields } from '../../../../common/search_strategy'; import { FieldValueThreshold } from '../../../detections/components/rules/threshold_input'; +import { GetLensAttributes, LensAttributes } from '../visualization_actions/types'; export type MatrixHistogramMappingTypes = Record< string, @@ -33,9 +34,11 @@ export type GetTitle = (matrixHistogramOption: MatrixHistogramOption) => string; export interface MatrixHistogramConfigs { defaultStackByOption: MatrixHistogramOption; errorMessage: string; + getLensAttributes?: GetLensAttributes; hideHistogramIfEmpty?: boolean; histogramType: MatrixHistogramType; legendPosition?: Position; + lensAttributes?: LensAttributes; mapping?: MatrixHistogramMappingTypes; stackByOptions: MatrixHistogramOption[]; subtitle?: string | GetSubTitle; diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx index 2d8d55a5c943f3..93a13dd5dee8b3 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx @@ -16,7 +16,7 @@ import { IconType, } from '@elastic/eui'; import { get, getOr } from 'lodash/fp'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -30,10 +30,14 @@ import { ChartSeriesData, ChartData, ChartSeriesConfigs, UpdateDateRange } from import { histogramDateTimeFormatter } from '../utils'; import { getEmptyTagValue } from '../empty_value'; -import { InspectButton, InspectButtonContainer } from '../inspect'; +import { InspectButton } from '../inspect'; +import { VisualizationActions, HISTOGRAM_ACTIONS_BUTTON_CLASS } from '../visualization_actions'; +import { HoverVisibilityContainer } from '../hover_visibility_container'; +import { LensAttributes } from '../visualization_actions/types'; const FlexItem = styled(EuiFlexItem)` min-width: 0; + position: relative; `; FlexItem.displayName = 'FlexItem'; @@ -53,6 +57,7 @@ interface StatItem { key: string; name?: string; value: number | undefined | null; + lensAttributes?: LensAttributes; } export interface StatItems { @@ -66,6 +71,8 @@ export interface StatItems { index?: number; key: string; statKey?: string; + barChartLensAttributes?: LensAttributes; + areaChartLensAttributes?: LensAttributes; } export interface StatItemsProps extends StatItems { @@ -75,6 +82,7 @@ export interface StatItemsProps extends StatItems { id: string; narrowDateRange: UpdateDateRange; to: string; + showInspectButton?: boolean; } export const numberFormatter = (value: string | number): string => value.toLocaleString(); @@ -126,7 +134,7 @@ export const addValueToAreaChart = ( ): ChartSeriesData[] => fields .filter((field) => get(`${field.key}Histogram`, data) != null) - .map((field) => ({ + .map(({ lensAttributes, ...field }) => ({ ...field, value: get(`${field.key}Histogram`, data), key: `${field.key}Histogram`, @@ -205,10 +213,13 @@ export const StatItemsComponent = React.memo( from, grow, id, + showInspectButton, index, narrowDateRange, statKey = 'item', to, + barChartLensAttributes, + areaChartLensAttributes, }) => { const isBarChartDataAvailable = barChart && @@ -219,58 +230,90 @@ export const StatItemsComponent = React.memo( areaChart.length && areaChart.every((item) => item.value != null && item.value.length > 0); + const timerange = useMemo( + () => ({ + from, + to, + }), + [from, to] + ); + return ( - - - - - -
{description}
-
-
+ + + + +
{description}
+
+
+ {showInspectButton && ( - + -
+ )} +
- - {fields.map((field) => ( - - - {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( - - - - )} + + {fields.map((field) => ( + + + {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( + + + + )} - + +

{field.value != null ? field.value.toLocaleString() : getEmptyTagValue()}{' '} {field.description}

-
-
-
- ))} -
+ {field.lensAttributes && timerange && ( + + )} + +
+
+
+ ))} + - {(enableAreaChart || enableBarChart) && } - - {enableBarChart && ( - - - - )} + {(enableAreaChart || enableBarChart) && } + + {enableBarChart && ( + + + + )} - {enableAreaChart && from != null && to != null && ( + {enableAreaChart && from != null && to != null && ( + <> ( xTickFormatter: histogramDateTimeFormatter([from, to]), onBrushEnd: narrowDateRange, })} + visualizationActionsOptions={{ + lensAttributes: areaChartLensAttributes, + queryId: id, + inspectIndex: index, + timerange, + title: description, + }} /> - )} - - - + + )} + + ); }, diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx index 83c05c28837238..ee84a49dc8230a 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx @@ -38,7 +38,9 @@ jest.mock('react-router-dom', () => { jest.mock('../link_to'); jest.mock('../../lib/kibana'); jest.mock('../../../timelines/store/timeline/actions'); - +jest.mock('../visualization_actions', () => ({ + VisualizationActions: jest.fn(() =>
), +})); const field = 'process.name'; const value = 'nice'; diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx index c5a8e93145353b..f100966185e33f 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx @@ -29,6 +29,9 @@ jest.mock('react-router-dom', () => { jest.mock('../../lib/kibana'); jest.mock('../link_to'); +jest.mock('../visualization_actions', () => ({ + VisualizationActions: jest.fn(() =>
), +})); jest.mock('uuid', () => { return { diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.test.tsx new file mode 100644 index 00000000000000..00ae0873472e9f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.test.tsx @@ -0,0 +1,241 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { dnsTopDomainsLensAttributes } from './lens_attributes/network/dns_top_domains'; +import { VisualizationActions } from '.'; +import { + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, + TestProviders, +} from '../../mock'; +import { createStore, State } from '../../store'; +import { UpdateQueryParams, upsertQuery } from '../../store/inputs/helpers'; +import { cloneDeep } from 'lodash'; +import { useKibana } from '../../lib/kibana/kibana_react'; +import { CASES_FEATURE_ID } from '../../../../common/constants'; +import { mockCasesContract } from '../../../../../cases/public/mocks'; +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useLocation: jest.fn(() => { + return { pathname: 'network' }; + }), + }; +}); +jest.mock('../../lib/kibana/kibana_react'); +jest.mock('../../utils/route/use_route_spy', () => { + return { + useRouteSpy: jest.fn(() => [{ pageName: 'network', detailName: '', tabName: 'dns' }]), + }; +}); +describe('VisualizationActions', () => { + const refetch = jest.fn(); + const state: State = mockGlobalState; + const { storage } = createSecuritySolutionStorageMock(); + const newQuery: UpdateQueryParams = { + inputId: 'global', + id: 'networkDnsHistogramQuery', + inspect: { + dsl: ['mockDsl'], + response: ['mockResponse'], + }, + loading: false, + refetch, + state: state.inputs, + }; + + let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + const props = { + lensAttributes: dnsTopDomainsLensAttributes, + queryId: 'networkDnsHistogramQuery', + timerange: { + from: '2022-03-06T16:00:00.000Z', + to: '2022-03-07T15:59:59.999Z', + }, + title: 'mock networkDnsHistogram', + }; + const mockNavigateToPrefilledEditor = jest.fn(); + const mockGetCreateCaseFlyoutOpen = jest.fn(); + const mockGetAllCasesSelectorModalOpen = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + lens: { + canUseEditor: jest.fn(() => true), + navigateToPrefilledEditor: mockNavigateToPrefilledEditor, + }, + cases: { + ...mockCasesContract(), + hooks: { + getUseCasesAddToExistingCaseModal: jest + .fn() + .mockReturnValue({ open: mockGetAllCasesSelectorModalOpen }), + getUseCasesAddToNewCaseFlyout: jest + .fn() + .mockReturnValue({ open: mockGetCreateCaseFlyoutOpen }), + }, + }, + application: { + capabilities: { [CASES_FEATURE_ID]: { crud_cases: true, read_cases: true } }, + getUrlForApp: jest.fn(), + navigateToApp: jest.fn(), + }, + notifications: { + toasts: { + addError: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + }, + }, + http: jest.fn(), + data: { + search: jest.fn(), + }, + storage: { + set: jest.fn(), + }, + theme: {}, + }, + }); + const myState = cloneDeep(state); + myState.inputs = upsertQuery(newQuery); + store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + }); + + test('Should render VisualizationActions button', () => { + const { container } = render( + + + + ); + expect( + container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`) + ).toBeInTheDocument(); + }); + + test('Should render Open in Lens button', () => { + const { container } = render( + + + + ); + fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); + + expect(screen.getByText('Open in Lens')).toBeInTheDocument(); + expect(screen.getByText('Open in Lens')).not.toBeDisabled(); + }); + + test('Should call NavigateToPrefilledEditor when Open in Lens', () => { + const { container } = render( + + + + ); + fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); + + fireEvent.click(screen.getByText('Open in Lens')); + expect(mockNavigateToPrefilledEditor.mock.calls[0][0].timeRange).toEqual(props.timerange); + expect(mockNavigateToPrefilledEditor.mock.calls[0][0].attributes.title).toEqual( + props.lensAttributes.title + ); + expect(mockNavigateToPrefilledEditor.mock.calls[0][0].attributes.references).toEqual([ + { + id: 'security-solution', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'security-solution', + name: 'indexpattern-datasource-layer-b1c3efc6-c886-4fba-978f-3b6bb5e7948a', + type: 'index-pattern', + }, + { id: 'security-solution', name: 'filter-index-pattern-0', type: 'index-pattern' }, + ]); + expect(mockNavigateToPrefilledEditor.mock.calls[0][1].openInNewTab).toEqual(true); + }); + + test('Should render Inspect button', () => { + const { container } = render( + + + + ); + fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); + + expect(screen.getByText('Inspect')).toBeInTheDocument(); + expect(screen.getByText('Inspect')).not.toBeDisabled(); + }); + + test('Should render Inspect Modal after clicking the inspect button', () => { + const { baseElement, container } = render( + + + + ); + fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); + + expect(screen.getByText('Inspect')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Inspect')); + expect( + baseElement.querySelector('[data-test-subj="modal-inspect-euiModal"]') + ).toBeInTheDocument(); + }); + + test('Should render Add to new case button', () => { + const { container } = render( + + + + ); + fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); + + expect(screen.getByText('Add to new case')).toBeInTheDocument(); + expect(screen.getByText('Add to new case')).not.toBeDisabled(); + }); + + test('Should render Add to new case modal after clicking on Add to new case button', () => { + const { container } = render( + + + + ); + fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); + fireEvent.click(screen.getByText('Add to new case')); + + expect(mockGetCreateCaseFlyoutOpen).toBeCalled(); + }); + + test('Should render Add to existing case button', () => { + const { container } = render( + + + + ); + fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); + + expect(screen.getByText('Add to existing case')).toBeInTheDocument(); + expect(screen.getByText('Add to existing case')).not.toBeDisabled(); + }); + + test('Should render Add to existing case modal after clicking on Add to existing case button', () => { + const { container } = render( + + + + ); + fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); + fireEvent.click(screen.getByText('Add to existing case')); + + expect(mockGetAllCasesSelectorModalOpen).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.tsx new file mode 100644 index 00000000000000..4ee0034ed4d02a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.tsx @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; + +import { useKibana } from '../../lib/kibana/kibana_react'; +import { ModalInspectQuery } from '../inspect/modal'; + +import { useInspect } from '../inspect/use_inspect'; +import { useLensAttributes } from './use_lens_attributes'; +import { useAddToExistingCase } from './use_add_to_existing_case'; +import { useGetUserCasesPermissions } from '../../lib/kibana'; +import { useAddToNewCase } from './use_add_to_new_case'; +import { VisualizationActionsProps } from './types'; +import { + ADD_TO_EXISTING_CASE, + ADD_TO_NEW_CASE, + INSPECT, + MORE_ACTIONS, + OPEN_IN_LENS, +} from './translations'; + +const Wrapper = styled.div` + &.viz-actions { + position: absolute; + top: 0; + right: 0; + } + &.histogram-viz-actions { + padding: ${({ theme }) => theme.eui.paddingSizes.s}; + } +`; + +export const HISTOGRAM_ACTIONS_BUTTON_CLASS = 'histogram-actions-trigger'; + +const VisualizationActionsComponent: React.FC = ({ + className, + getLensAttributes, + inputId = 'global', + inspectIndex = 0, + isInspectButtonDisabled, + isMultipleQuery, + lensAttributes, + onCloseInspect, + queryId, + timerange, + title, + stackByField, +}) => { + const { lens } = useKibana().services; + const userPermissions = useGetUserCasesPermissions(); + const userCanCrud = userPermissions?.crud ?? false; + + const { canUseEditor, navigateToPrefilledEditor } = lens; + const [isPopoverOpen, setPopover] = useState(false); + const [isInspectModalOpen, setIsInspectModalOpen] = useState(false); + + const onButtonClick = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); + + const closePopover = () => { + setPopover(false); + }; + + const attributes = useLensAttributes({ + lensAttributes, + getLensAttributes, + stackByField, + }); + + const dataTestSubj = `stat-${queryId}`; + + const { disabled: isAddToExistingCaseDisabled, onAddToExistingCaseClicked } = + useAddToExistingCase({ + onAddToCaseClicked: closePopover, + lensAttributes: attributes, + timeRange: timerange, + userCanCrud, + }); + + const { onAddToNewCaseClicked, disabled: isAddToNewCaseDisabled } = useAddToNewCase({ + onClick: closePopover, + timeRange: timerange, + lensAttributes: attributes, + userCanCrud, + }); + + const onOpenInLens = useCallback(() => { + closePopover(); + if (!timerange || !attributes) { + return; + } + navigateToPrefilledEditor( + { + id: '', + timeRange: timerange, + attributes, + }, + { + openInNewTab: true, + } + ); + }, [attributes, navigateToPrefilledEditor, timerange]); + + const onOpenInspectModal = useCallback(() => { + closePopover(); + setIsInspectModalOpen(true); + }, []); + + const onCloseInspectModal = useCallback(() => { + setIsInspectModalOpen(false); + if (onCloseInspect) { + onCloseInspect(); + } + }, [onCloseInspect]); + + const { + additionalRequests, + additionalResponses, + handleClick: handleInspectButtonClick, + handleCloseModal: handleCloseInspectModal, + isButtonDisabled: disableInspectButton, + request, + response, + } = useInspect({ + inputId, + inspectIndex, + isDisabled: isInspectButtonDisabled, + multiple: isMultipleQuery, + onCloseInspect: onCloseInspectModal, + onClick: onOpenInspectModal, + queryId, + }); + + const disabledOpenInLens = useMemo( + () => !canUseEditor() || attributes == null, + [attributes, canUseEditor] + ); + + const items = useMemo( + () => [ + + {INSPECT} + , + + {OPEN_IN_LENS} + , + + {ADD_TO_NEW_CASE} + , + + {ADD_TO_EXISTING_CASE} + , + ], + [ + disableInspectButton, + disabledOpenInLens, + handleInspectButtonClick, + isAddToExistingCaseDisabled, + isAddToNewCaseDisabled, + onAddToExistingCaseClicked, + onAddToNewCaseClicked, + onOpenInLens, + ] + ); + + const button = useMemo( + () => ( + + ), + [dataTestSubj, onButtonClick] + ); + + return ( + + {request !== null && response !== null && ( + + + + )} + {isInspectModalOpen && request !== null && response !== null && ( + + )} + + ); +}; + +VisualizationActionsComponent.displayName = 'VisualizationActionsComponent'; +export const VisualizationActions = React.memo(VisualizationActionsComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/external_alert.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/external_alert.ts new file mode 100644 index 00000000000000..551ce37be924e1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/external_alert.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GetLensAttributes, LensAttributes } from '../../types'; + +export const getExternalAlertLensAttributes: GetLensAttributes = ( + stackByField = 'event.module' +) => { + return { + title: 'External alerts', + description: '', + visualizationType: 'lnsXY', + state: { + visualization: { + title: 'Empty XY chart', + legend: { + isVisible: true, + position: 'right', + }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: 'a3c54471-615f-4ff9-9fda-69b5b2ea3eef', + accessors: ['0a923af2-c880-4aa3-aa93-a0b9c2801f6d'], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + layerType: 'data', + xAccessor: '37bdf546-3c11-4b08-8c5d-e37debc44f1d', + splitAccessor: '42334c6e-98d9-47a2-b4cb-a445abb44c93', + }, + ], + yRightExtent: { + mode: 'full', + }, + yLeftExtent: { + mode: 'full', + }, + }, + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + index: 'a04472fc-94a3-4b8d-ae05-9d30ea8fbd6a', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'event.kind', + params: { + query: 'alert', + }, + }, + query: { + match_phrase: { + 'event.kind': 'alert', + }, + }, + $state: { + store: 'appState', + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + 'a3c54471-615f-4ff9-9fda-69b5b2ea3eef': { + columns: { + '37bdf546-3c11-4b08-8c5d-e37debc44f1d': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { + interval: 'auto', + }, + }, + '0a923af2-c880-4aa3-aa93-a0b9c2801f6d': { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + }, + '42334c6e-98d9-47a2-b4cb-a445abb44c93': { + label: `Top values of ${stackByField}`, // could be event.category + dataType: 'string', + operationType: 'terms', + scale: 'ordinal', + sourceField: `${stackByField}`, // could be event.category + isBucketed: true, + params: { + size: 10, + orderBy: { + type: 'column', + columnId: '0a923af2-c880-4aa3-aa93-a0b9c2801f6d', + }, + orderDirection: 'desc', + otherBucket: true, + missingBucket: false, + parentFormat: { + id: 'terms', + }, + }, + }, + }, + columnOrder: [ + '42334c6e-98d9-47a2-b4cb-a445abb44c93', + '37bdf546-3c11-4b08-8c5d-e37debc44f1d', + '0a923af2-c880-4aa3-aa93-a0b9c2801f6d', + ], + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-a3c54471-615f-4ff9-9fda-69b5b2ea3eef', + }, + { + type: 'index-pattern', + name: '723c4653-681b-4105-956e-abef287bf025', + id: '{dataViewId}', + }, + { + type: 'index-pattern', + name: 'a04472fc-94a3-4b8d-ae05-9d30ea8fbd6a', + id: '{dataViewId}', + }, + ], + } as LensAttributes; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/authentication.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/authentication.ts new file mode 100644 index 00000000000000..3610dcb4c94ae5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/authentication.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const authenticationLensAttributes: LensAttributes = { + title: 'Authentication', + description: '', + visualizationType: 'lnsXY', + state: { + visualization: { + title: 'Empty XY chart', + legend: { + isVisible: true, + position: 'right', + }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '3fd0c5d5-f762-4a27-8c56-14eee0223e13', + accessors: ['5417777d-d9d9-4268-9cdc-eb29b873bd65'], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + layerType: 'data', + xAccessor: 'b41a2958-650b-470a-84c4-c6fd8f0c6d37', + yConfig: [ + { + forAccessor: '5417777d-d9d9-4268-9cdc-eb29b873bd65', + color: '#54b399', + }, + ], + }, + { + layerId: 'bef502be-e5ff-442f-9e3e-229f86ca2afa', + seriesType: 'bar_stacked', + accessors: ['a3bf9dc1-c8d2-42d6-9e60-31892a4c509e'], + layerType: 'data', + xAccessor: 'cded27f7-8ef8-458c-8d9b-70db48ae340d', + yConfig: [ + { + forAccessor: 'a3bf9dc1-c8d2-42d6-9e60-31892a4c509e', + color: '#da8b45', + }, + ], + }, + ], + yRightExtent: { + mode: 'full', + }, + yLeftExtent: { + mode: 'full', + }, + axisTitlesVisibilitySettings: { + x: false, + yLeft: false, + yRight: true, + }, + }, + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + index: '6f4dbdc7-35b6-4e20-ac53-1272167e3919', + type: 'custom', + disabled: false, + negate: false, + alias: null, + key: 'query', + value: '{"bool":{"must":[{"term":{"event.category":"authentication"}}]}}', + }, + $state: { + store: 'appState', + }, + query: { + bool: { + must: [ + { + term: { + 'event.category': 'authentication', + }, + }, + ], + }, + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + '3fd0c5d5-f762-4a27-8c56-14eee0223e13': { + columns: { + 'b41a2958-650b-470a-84c4-c6fd8f0c6d37': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { + interval: 'auto', + }, + }, + '5417777d-d9d9-4268-9cdc-eb29b873bd65': { + label: 'Success', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + filter: { + query: 'event.outcome : "success"', + language: 'kuery', + }, + customLabel: true, + }, + }, + columnOrder: [ + 'b41a2958-650b-470a-84c4-c6fd8f0c6d37', + '5417777d-d9d9-4268-9cdc-eb29b873bd65', + ], + incompleteColumns: {}, + }, + 'bef502be-e5ff-442f-9e3e-229f86ca2afa': { + columns: { + 'cded27f7-8ef8-458c-8d9b-70db48ae340d': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { + interval: 'auto', + }, + }, + 'a3bf9dc1-c8d2-42d6-9e60-31892a4c509e': { + label: 'Failure', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + filter: { + query: 'event.outcome : "failure"', + language: 'kuery', + }, + customLabel: true, + }, + }, + columnOrder: [ + 'cded27f7-8ef8-458c-8d9b-70db48ae340d', + 'a3bf9dc1-c8d2-42d6-9e60-31892a4c509e', + ], + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-3fd0c5d5-f762-4a27-8c56-14eee0223e13', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-bef502be-e5ff-442f-9e3e-229f86ca2afa', + }, + { + type: 'index-pattern', + name: '6f4dbdc7-35b6-4e20-ac53-1272167e3919', + id: '{dataViewId}', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/events.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/events.ts new file mode 100644 index 00000000000000..0e5284f84bf1f8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/events.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GetLensAttributes, LensAttributes } from '../../types'; + +export const getEventsHistogramLensAttributes: GetLensAttributes = ( + stackByField = 'event.action' +) => + ({ + title: 'Host - events', + description: '', + visualizationType: 'lnsXY', + state: { + visualization: { + title: 'Empty XY chart', + legend: { + isVisible: true, + position: 'right', + }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '0039eb0c-9a1a-4687-ae54-0f4e239bec75', + accessors: ['e09e0380-0740-4105-becc-0a4ca12e3944'], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + layerType: 'data', + xAccessor: 'aac9d7d0-13a3-480a-892b-08207a787926', + splitAccessor: '34919782-4546-43a5-b668-06ac934d3acd', + }, + ], + yRightExtent: { + mode: 'full', + }, + yLeftExtent: { + mode: 'full', + }, + axisTitlesVisibilitySettings: { + x: false, + yLeft: false, + yRight: true, + }, + }, + query: { + query: '', + language: 'kuery', + }, + filters: [], + datasourceStates: { + indexpattern: { + layers: { + '0039eb0c-9a1a-4687-ae54-0f4e239bec75': { + columns: { + 'aac9d7d0-13a3-480a-892b-08207a787926': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { + interval: 'auto', + }, + }, + 'e09e0380-0740-4105-becc-0a4ca12e3944': { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + }, + '34919782-4546-43a5-b668-06ac934d3acd': { + label: `Top values of ${stackByField}`, // could be event.dataset or event.module + dataType: 'string', + operationType: 'terms', + scale: 'ordinal', + sourceField: `${stackByField}`, // could be event.dataset or event.module + isBucketed: true, + params: { + size: 10, + orderBy: { + type: 'column', + columnId: 'e09e0380-0740-4105-becc-0a4ca12e3944', + }, + orderDirection: 'asc', + otherBucket: true, + missingBucket: false, + parentFormat: { + id: 'terms', + }, + }, + }, + }, + columnOrder: [ + '34919782-4546-43a5-b668-06ac934d3acd', + 'aac9d7d0-13a3-480a-892b-08207a787926', + 'e09e0380-0740-4105-becc-0a4ca12e3944', + ], + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-0039eb0c-9a1a-4687-ae54-0f4e239bec75', + }, + ], + } as LensAttributes); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.ts new file mode 100644 index 00000000000000..369bbf3da2ab10 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const kpiHostAreaLensAttributes: LensAttributes = { + description: '', + state: { + datasourceStates: { + indexpattern: { + layers: { + '416b6fad-1923-4f6a-a2df-b223bb287e30': { + columnOrder: [ + '5eea817b-67b7-4268-8ecb-7688d1094721', + 'b00c65ea-32be-4163-bfc8-f795b1ef9d06', + ], + columns: { + '5eea817b-67b7-4268-8ecb-7688d1094721': { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + sourceField: '@timestamp', + }, + 'b00c65ea-32be-4163-bfc8-f795b1ef9d06': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: ' ', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'host.name', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: { + axisTitlesVisibilitySettings: { x: false, yLeft: false, yRight: false }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + labelsOrientation: { x: 0, yLeft: 0, yRight: 0 }, + layers: [ + { + accessors: ['b00c65ea-32be-4163-bfc8-f795b1ef9d06'], + layerId: '416b6fad-1923-4f6a-a2df-b223bb287e30', + layerType: 'data', + seriesType: 'area', + xAccessor: '5eea817b-67b7-4268-8ecb-7688d1094721', + }, + ], + legend: { isVisible: true, position: 'right' }, + preferredSeriesType: 'area', + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + valueLabels: 'hide', + yLeftExtent: { mode: 'full' }, + yRightExtent: { mode: 'full' }, + }, + }, + title: '[Host] Hosts - area', + visualizationType: 'lnsXY', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-416b6fad-1923-4f6a-a2df-b223bb287e30', + type: 'index-pattern', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.ts new file mode 100644 index 00000000000000..9ce303b70df0a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const kpiHostMetricLensAttributes: LensAttributes = { + description: '', + state: { + datasourceStates: { + indexpattern: { + layers: { + '416b6fad-1923-4f6a-a2df-b223bb287e30': { + columnOrder: ['b00c65ea-32be-4163-bfc8-f795b1ef9d06'], + columns: { + 'b00c65ea-32be-4163-bfc8-f795b1ef9d06': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: ' ', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'host.name', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: { + accessor: 'b00c65ea-32be-4163-bfc8-f795b1ef9d06', + layerId: '416b6fad-1923-4f6a-a2df-b223bb287e30', + layerType: 'data', + }, + }, + title: '[Host] Hosts - metric', + visualizationType: 'lnsMetric', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-416b6fad-1923-4f6a-a2df-b223bb287e30', + type: 'index-pattern', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area.ts new file mode 100644 index 00000000000000..577a20cfdc2459 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DESTINATION_CHART_LABEL, SOURCE_CHART_LABEL } from '../../translations'; +import { LensAttributes } from '../../types'; + +export const kpiUniqueIpsAreaLensAttributes: LensAttributes = { + description: '', + state: { + datasourceStates: { + indexpattern: { + layers: { + '8be0156b-d423-4a39-adf1-f54d4c9f2e69': { + columnOrder: [ + 'a0cb6400-f708-46c3-ad96-24788f12dae4', + 'd9a6eb6b-8b78-439e-98e7-a718f8ffbebe', + ], + columns: { + 'a0cb6400-f708-46c3-ad96-24788f12dae4': { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + sourceField: '@timestamp', + }, + 'd9a6eb6b-8b78-439e-98e7-a718f8ffbebe': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: SOURCE_CHART_LABEL, + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'source.ip', + }, + }, + incompleteColumns: {}, + }, + 'ca05ecdb-0fa4-49a8-9305-b23d91012a46': { + columnOrder: [ + 'f95e74e6-99dd-4b11-8faf-439b4d959df9', + 'e7052671-fb9e-481f-8df3-7724c98cfc6f', + ], + columns: { + 'e7052671-fb9e-481f-8df3-7724c98cfc6f': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: DESTINATION_CHART_LABEL, + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'destination.ip', + }, + 'f95e74e6-99dd-4b11-8faf-439b4d959df9': { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + sourceField: '@timestamp', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: { + axisTitlesVisibilitySettings: { x: false, yLeft: false, yRight: true }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + labelsOrientation: { x: 0, yLeft: 0, yRight: 0 }, + layers: [ + { + accessors: ['d9a6eb6b-8b78-439e-98e7-a718f8ffbebe'], + layerId: '8be0156b-d423-4a39-adf1-f54d4c9f2e69', + layerType: 'data', + seriesType: 'area', + xAccessor: 'a0cb6400-f708-46c3-ad96-24788f12dae4', + yConfig: [{ color: '#d36186', forAccessor: 'd9a6eb6b-8b78-439e-98e7-a718f8ffbebe' }], + }, + { + accessors: ['e7052671-fb9e-481f-8df3-7724c98cfc6f'], + layerId: 'ca05ecdb-0fa4-49a8-9305-b23d91012a46', + layerType: 'data', + seriesType: 'area', + xAccessor: 'f95e74e6-99dd-4b11-8faf-439b4d959df9', + yConfig: [{ color: '#9170b8', forAccessor: 'e7052671-fb9e-481f-8df3-7724c98cfc6f' }], + }, + ], + legend: { isVisible: false, position: 'right', showSingleSeries: false }, + preferredSeriesType: 'area', + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + valueLabels: 'hide', + yLeftExtent: { mode: 'full' }, + yRightExtent: { mode: 'full' }, + }, + }, + title: '[Host] Unique IPs - area', + visualizationType: 'lnsXY', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-8be0156b-d423-4a39-adf1-f54d4c9f2e69', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-ca05ecdb-0fa4-49a8-9305-b23d91012a46', + type: 'index-pattern', + }, + ], + type: 'lens', + updated_at: '2022-02-09T17:44:03.359Z', + version: 'WzI5MTI5OSwzXQ==', +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_bar.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_bar.ts new file mode 100644 index 00000000000000..b55f71abb75443 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_bar.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; +import { SOURCE_CHART_LABEL, DESTINATION_CHART_LABEL } from '../../translations'; + +export const kpiUniqueIpsBarLensAttributes: LensAttributes = { + description: '', + state: { + datasourceStates: { + indexpattern: { + layers: { + '8be0156b-d423-4a39-adf1-f54d4c9f2e69': { + columnOrder: [ + 'f8bfa719-5c1c-4bf2-896e-c318d77fc08e', + '32f66676-f4e1-48fd-b7f8-d4de38318601', + ], + columns: { + '32f66676-f4e1-48fd-b7f8-d4de38318601': { + dataType: 'number', + isBucketed: false, + label: 'Unique count of source.ip', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'source.ip', + }, + 'f8bfa719-5c1c-4bf2-896e-c318d77fc08e': { + dataType: 'string', + isBucketed: true, + label: 'Filters', + operationType: 'filters', + params: { + filters: [{ input: { language: 'kuery', query: '' }, label: SOURCE_CHART_LABEL }], + }, + scale: 'ordinal', + }, + }, + incompleteColumns: {}, + }, + 'ec84ba70-2adb-4647-8ef0-8ad91a0e6d4e': { + columnOrder: [ + 'c72aad6a-fc9c-43dc-9194-e13ca3ee8aff', + 'b7e59b08-96e6-40d1-84fd-e97b977d1c47', + ], + columns: { + 'b7e59b08-96e6-40d1-84fd-e97b977d1c47': { + dataType: 'number', + isBucketed: false, + label: 'Unique count of destination.ip', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'destination.ip', + }, + 'c72aad6a-fc9c-43dc-9194-e13ca3ee8aff': { + customLabel: true, + dataType: 'string', + isBucketed: true, + label: DESTINATION_CHART_LABEL, + operationType: 'filters', + params: { + filters: [{ input: { language: 'kuery', query: '' }, label: 'Dest.' }], + }, + scale: 'ordinal', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: { + axisTitlesVisibilitySettings: { x: false, yLeft: false, yRight: true }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + labelsOrientation: { x: 0, yLeft: 0, yRight: 0 }, + layers: [ + { + accessors: ['32f66676-f4e1-48fd-b7f8-d4de38318601'], + layerId: '8be0156b-d423-4a39-adf1-f54d4c9f2e69', + layerType: 'data', + seriesType: 'bar_horizontal_stacked', + xAccessor: 'f8bfa719-5c1c-4bf2-896e-c318d77fc08e', + yConfig: [{ color: '#d36186', forAccessor: '32f66676-f4e1-48fd-b7f8-d4de38318601' }], + }, + { + accessors: ['b7e59b08-96e6-40d1-84fd-e97b977d1c47'], + layerId: 'ec84ba70-2adb-4647-8ef0-8ad91a0e6d4e', + layerType: 'data', + seriesType: 'bar_horizontal_stacked', + xAccessor: 'c72aad6a-fc9c-43dc-9194-e13ca3ee8aff', + yConfig: [{ color: '#9170b8', forAccessor: 'b7e59b08-96e6-40d1-84fd-e97b977d1c47' }], + }, + ], + legend: { isVisible: false, position: 'right', showSingleSeries: false }, + preferredSeriesType: 'bar_horizontal_stacked', + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + valueLabels: 'hide', + yLeftExtent: { mode: 'full' }, + yRightExtent: { mode: 'full' }, + }, + }, + title: '[Host] Unique IPs - bar', + visualizationType: 'lnsXY', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-8be0156b-d423-4a39-adf1-f54d4c9f2e69', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-ec84ba70-2adb-4647-8ef0-8ad91a0e6d4e', + type: 'index-pattern', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_destination_metric.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_destination_metric.ts new file mode 100644 index 00000000000000..c70efd904cfb30 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_destination_metric.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const kpiUniqueIpsDestinationMetricLensAttributes: LensAttributes = { + description: '', + state: { + datasourceStates: { + indexpattern: { + layers: { + '8be0156b-d423-4a39-adf1-f54d4c9f2e69': { + columnOrder: ['d9a6eb6b-8b78-439e-98e7-a718f8ffbebe'], + columns: { + 'd9a6eb6b-8b78-439e-98e7-a718f8ffbebe': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: ' ', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'destination.ip', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: { + accessor: 'd9a6eb6b-8b78-439e-98e7-a718f8ffbebe', + layerId: '8be0156b-d423-4a39-adf1-f54d4c9f2e69', + layerType: 'data', + }, + }, + title: '[Host] Unique IPs - destination metric', + visualizationType: 'lnsMetric', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-8be0156b-d423-4a39-adf1-f54d4c9f2e69', + type: 'index-pattern', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_source_metric.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_source_metric.ts new file mode 100644 index 00000000000000..a1325e0d94e0c0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_source_metric.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const kpiUniqueIpsSourceMetricLensAttributes: LensAttributes = { + description: '', + state: { + datasourceStates: { + indexpattern: { + layers: { + '8be0156b-d423-4a39-adf1-f54d4c9f2e69': { + columnOrder: ['d9a6eb6b-8b78-439e-98e7-a718f8ffbebe'], + columns: { + 'd9a6eb6b-8b78-439e-98e7-a718f8ffbebe': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: ' ', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'source.ip', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: { + accessor: 'd9a6eb6b-8b78-439e-98e7-a718f8ffbebe', + layerId: '8be0156b-d423-4a39-adf1-f54d4c9f2e69', + layerType: 'data', + }, + }, + title: '[Host] Unique IPs - source metric', + visualizationType: 'lnsMetric', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-8be0156b-d423-4a39-adf1-f54d4c9f2e69', + type: 'index-pattern', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentication_metric_failure.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentication_metric_failure.ts new file mode 100644 index 00000000000000..459ad6693ae41a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentication_metric_failure.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const kpiUserAuthenticationsMetricFailureLensAttributes: LensAttributes = { + title: '[Host] User authentications - metric failure ', + description: '', + visualizationType: 'lnsMetric', + state: { + visualization: { + accessor: '0eb97c09-a351-4280-97da-944e4bd30dd7', + layerId: '4590dafb-4ac7-45aa-8641-47a3ff0b817c', + layerType: 'data', + }, + query: { + language: 'kuery', + query: '', + }, + filters: [ + { + $state: { + store: 'appState', + }, + meta: { + alias: null, + disabled: false, + indexRefName: 'filter-index-pattern-0', + key: 'query', + negate: false, + type: 'custom', + value: '{"bool":{"filter":[{"term":{"event.category":"authentication"}}]}}', + }, + query: { + bool: { + filter: [ + { + term: { + 'event.category': 'authentication', + }, + }, + ], + }, + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + '4590dafb-4ac7-45aa-8641-47a3ff0b817c': { + columnOrder: ['0eb97c09-a351-4280-97da-944e4bd30dd7'], + columns: { + '0eb97c09-a351-4280-97da-944e4bd30dd7': { + dataType: 'number', + filter: { + language: 'kuery', + query: 'event.outcome : "failure" ', + }, + isBucketed: false, + label: '', + operationType: 'count', + scale: 'ratio', + sourceField: '___records___', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-4590dafb-4ac7-45aa-8641-47a3ff0b817c', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_area.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_area.ts new file mode 100644 index 00000000000000..ec0770795f5a80 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_area.ts @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const kpiUserAuthenticationsAreaLensAttributes: LensAttributes = { + title: '[Host] User authentications - area ', + description: '', + visualizationType: 'lnsXY', + state: { + visualization: { + axisTitlesVisibilitySettings: { + x: true, + yLeft: false, + yRight: true, + }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + layers: [ + { + accessors: ['0eb97c09-a351-4280-97da-944e4bd30dd7'], + layerId: '4590dafb-4ac7-45aa-8641-47a3ff0b817c', + layerType: 'data', + seriesType: 'area', + xAccessor: '49a42fe6-ebe8-4adb-8eed-1966a5297b7e', + yConfig: [ + { + color: '#54b399', + forAccessor: '0eb97c09-a351-4280-97da-944e4bd30dd7', + }, + ], + }, + { + accessors: ['2b27c80e-a20d-46f1-8fb2-79626ef4563c'], + layerId: '31213ae3-905b-4e88-b987-0cccb1f3209f', + layerType: 'data', + seriesType: 'area', + xAccessor: '33a6163d-0c0a-451d-aa38-8ca6010dd5bf', + yConfig: [ + { + color: '#e7664c', + forAccessor: '2b27c80e-a20d-46f1-8fb2-79626ef4563c', + }, + ], + }, + ], + legend: { + isVisible: false, + position: 'right', + showSingleSeries: false, + }, + preferredSeriesType: 'area', + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + valueLabels: 'hide', + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + query: { + language: 'kuery', + query: '', + }, + filters: [ + { + $state: { + store: 'appState', + }, + meta: { + alias: null, + disabled: false, + indexRefName: 'filter-index-pattern-0', + key: 'query', + negate: false, + type: 'custom', + value: '{"bool":{"filter":[{"term":{"event.category":"authentication"}}]}}', + }, + query: { + bool: { + filter: [ + { + term: { + 'event.category': 'authentication', + }, + }, + ], + }, + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + '31213ae3-905b-4e88-b987-0cccb1f3209f': { + columnOrder: [ + '33a6163d-0c0a-451d-aa38-8ca6010dd5bf', + '2b27c80e-a20d-46f1-8fb2-79626ef4563c', + ], + columns: { + '2b27c80e-a20d-46f1-8fb2-79626ef4563c': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: 'event.outcome: "failure" ', + }, + isBucketed: false, + label: 'Fail', + operationType: 'count', + scale: 'ratio', + sourceField: '___records___', + }, + '33a6163d-0c0a-451d-aa38-8ca6010dd5bf': { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { + interval: 'auto', + }, + scale: 'interval', + sourceField: '@timestamp', + }, + }, + incompleteColumns: {}, + }, + '4590dafb-4ac7-45aa-8641-47a3ff0b817c': { + columnOrder: [ + '49a42fe6-ebe8-4adb-8eed-1966a5297b7e', + '0eb97c09-a351-4280-97da-944e4bd30dd7', + ], + columns: { + '0eb97c09-a351-4280-97da-944e4bd30dd7': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: 'event.outcome : "success" ', + }, + isBucketed: false, + label: 'Succ.', + operationType: 'count', + scale: 'ratio', + sourceField: '___records___', + }, + '49a42fe6-ebe8-4adb-8eed-1966a5297b7e': { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { + interval: 'auto', + }, + scale: 'interval', + sourceField: '@timestamp', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: '{dataViewId}', + id: 'security-solution-default', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: '{dataViewId}', + id: 'security-solution-default', + name: 'indexpattern-datasource-layer-31213ae3-905b-4e88-b987-0cccb1f3209f', + }, + { + type: '{dataViewId}', + id: 'security-solution-default', + name: 'indexpattern-datasource-layer-4590dafb-4ac7-45aa-8641-47a3ff0b817c', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_bar.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_bar.ts new file mode 100644 index 00000000000000..02468984144bcd --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_bar.ts @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; +import { FAIL_CHART_LABEL, SUCCESS_CHART_LABEL } from '../../translations'; + +export const kpiUserAuthenticationsBarLensAttributes: LensAttributes = { + title: '[Host] User authentications - bar ', + description: '', + visualizationType: 'lnsXY', + state: { + visualization: { + axisTitlesVisibilitySettings: { + x: false, + yLeft: false, + yRight: true, + }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + layers: [ + { + accessors: ['938b445a-a291-4bbc-84fe-4f47b69c20e4'], + layerId: '31213ae3-905b-4e88-b987-0cccb1f3209f', + layerType: 'data', + seriesType: 'bar_horizontal_stacked', + xAccessor: '430e690c-9992-414f-9bce-00812d99a5e7', + yConfig: [], + }, + { + accessors: ['c8165fc3-7180-4f1b-8c87-bc3ea04c6df7'], + layerId: 'b9acd453-f476-4467-ad38-203e37b73e55', + layerType: 'data', + seriesType: 'bar_horizontal_stacked', + xAccessor: 'e959c351-a3a2-4525-b244-9623f215a8fd', + yConfig: [ + { + color: '#e7664c', + forAccessor: 'c8165fc3-7180-4f1b-8c87-bc3ea04c6df7', + }, + ], + }, + ], + legend: { + isVisible: false, + position: 'right', + showSingleSeries: false, + }, + preferredSeriesType: 'bar_horizontal_stacked', + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + valueLabels: 'hide', + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + query: { + language: 'kuery', + query: '', + }, + filters: [ + { + $state: { + store: 'appState', + }, + meta: { + alias: null, + disabled: false, + indexRefName: 'filter-index-pattern-0', + key: 'query', + negate: false, + type: 'custom', + value: '{"bool":{"filter":[{"term":{"event.category":"authentication"}}]}}', + }, + query: { + bool: { + filter: [ + { + term: { + 'event.category': 'authentication', + }, + }, + ], + }, + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + '31213ae3-905b-4e88-b987-0cccb1f3209f': { + columnOrder: [ + '430e690c-9992-414f-9bce-00812d99a5e7', + '938b445a-a291-4bbc-84fe-4f47b69c20e4', + ], + columns: { + '430e690c-9992-414f-9bce-00812d99a5e7': { + dataType: 'string', + isBucketed: true, + label: 'Filters', + operationType: 'filters', + params: { + filters: [ + { + input: { + language: 'kuery', + query: 'event.outcome : "success" ', + }, + label: SUCCESS_CHART_LABEL, + }, + ], + }, + scale: 'ordinal', + }, + '938b445a-a291-4bbc-84fe-4f47b69c20e4': { + dataType: 'number', + isBucketed: false, + label: SUCCESS_CHART_LABEL, + operationType: 'count', + scale: 'ratio', + sourceField: '___records___', + }, + }, + incompleteColumns: {}, + }, + 'b9acd453-f476-4467-ad38-203e37b73e55': { + columnOrder: [ + 'e959c351-a3a2-4525-b244-9623f215a8fd', + 'c8165fc3-7180-4f1b-8c87-bc3ea04c6df7', + ], + columns: { + 'c8165fc3-7180-4f1b-8c87-bc3ea04c6df7': { + dataType: 'number', + isBucketed: false, + label: 'Fail', + operationType: 'count', + scale: 'ratio', + sourceField: '___records___', + }, + 'e959c351-a3a2-4525-b244-9623f215a8fd': { + customLabel: true, + dataType: 'string', + isBucketed: true, + label: FAIL_CHART_LABEL, + operationType: 'filters', + params: { + filters: [ + { + input: { + language: 'kuery', + query: 'event.outcome:"failure" ', + }, + label: FAIL_CHART_LABEL, + }, + ], + }, + scale: 'ordinal', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-31213ae3-905b-4e88-b987-0cccb1f3209f', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-b9acd453-f476-4467-ad38-203e37b73e55', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_metric_success.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_metric_success.ts new file mode 100644 index 00000000000000..ae0ac6e3e2e4d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_metric_success.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const kpiUserAuthenticationsMetricSuccessLensAttributes: LensAttributes = { + title: '[Host] User authentications - metric success ', + description: '', + visualizationType: 'lnsMetric', + state: { + visualization: { + accessor: '0eb97c09-a351-4280-97da-944e4bd30dd7', + layerId: '4590dafb-4ac7-45aa-8641-47a3ff0b817c', + layerType: 'data', + }, + query: { + language: 'kuery', + query: '', + }, + filters: [ + { + $state: { + store: 'appState', + }, + meta: { + alias: null, + disabled: false, + indexRefName: 'filter-index-pattern-0', + key: 'query', + negate: false, + type: 'custom', + value: '{"bool":{"filter":[{"term":{"event.category":"authentication"}}]}}', + }, + query: { + bool: { + filter: [ + { + term: { + 'event.category': 'authentication', + }, + }, + ], + }, + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + '4590dafb-4ac7-45aa-8641-47a3ff0b817c': { + columnOrder: ['0eb97c09-a351-4280-97da-944e4bd30dd7'], + columns: { + '0eb97c09-a351-4280-97da-944e4bd30dd7': { + customLabel: true, + dataType: 'number', + filter: { + language: 'kuery', + query: 'event.outcome : "success" ', + }, + isBucketed: false, + label: ' ', + operationType: 'count', + scale: 'ratio', + sourceField: '___records___', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-4590dafb-4ac7-45aa-8641-47a3ff0b817c', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/dns_top_domains.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/dns_top_domains.ts new file mode 100644 index 00000000000000..579d6f0b3ab7e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/dns_top_domains.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +/* Exported from Kibana Saved Object */ +export const dnsTopDomainsLensAttributes: LensAttributes = { + title: 'Top domains by dns.question.registered_domain', + description: 'Security Solution Network DNS', + visualizationType: 'lnsXY', + state: { + visualization: { + legend: { + isVisible: true, + position: 'right', + }, + valueLabels: 'hide', + fittingFunction: 'None', + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + axisTitlesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + preferredSeriesType: 'bar', + layers: [ + { + layerId: 'b1c3efc6-c886-4fba-978f-3b6bb5e7948a', + accessors: ['2a4d5e20-f570-48e4-b9ab-ff3068919377'], + position: 'top', + seriesType: 'bar', + showGridlines: false, + layerType: 'data', + xAccessor: 'd1452b87-0e9e-4fc0-a725-3727a18e0b37', + splitAccessor: 'e8842815-2a45-4c74-86de-c19a391e2424', + }, + ], + }, + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key: 'dns.question.type', + params: { + query: 'PTR', + }, + indexRefName: 'filter-index-pattern-0', + }, + query: { + match_phrase: { + 'dns.question.type': 'PTR', + }, + }, + $state: { + store: 'appState', + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + 'b1c3efc6-c886-4fba-978f-3b6bb5e7948a': { + columns: { + 'd1452b87-0e9e-4fc0-a725-3727a18e0b37': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { + interval: 'auto', + }, + }, + '2a4d5e20-f570-48e4-b9ab-ff3068919377': { + label: 'Unique count of dns.question.registered_domain', + dataType: 'number', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'dns.question.registered_domain', + isBucketed: false, + }, + 'e8842815-2a45-4c74-86de-c19a391e2424': { + label: 'Top values of dns.question.name', + dataType: 'string', + operationType: 'terms', + scale: 'ordinal', + sourceField: 'dns.question.name', + isBucketed: true, + params: { + size: 6, + orderBy: { + type: 'column', + columnId: '2a4d5e20-f570-48e4-b9ab-ff3068919377', + }, + orderDirection: 'desc', + otherBucket: true, + missingBucket: false, + }, + }, + }, + columnOrder: [ + 'e8842815-2a45-4c74-86de-c19a391e2424', + 'd1452b87-0e9e-4fc0-a725-3727a18e0b37', + '2a4d5e20-f570-48e4-b9ab-ff3068919377', + ], + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-b1c3efc6-c886-4fba-978f-3b6bb5e7948a', + }, + { + name: 'filter-index-pattern-0', + type: 'index-pattern', + id: '{dataViewId}', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_dns_queries.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_dns_queries.ts new file mode 100644 index 00000000000000..3b672c03d97f23 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_dns_queries.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const kpiDnsQueriesLensAttributes: LensAttributes = { + title: '[Network] DNS metric', + description: '', + visualizationType: 'lnsMetric', + state: { + visualization: { + layerId: 'cea37c70-8f91-43bf-b9fe-72d8c049f6a3', + accessor: '0374e520-eae0-4ac1-bcfe-37565e7fc9e3', + layerType: 'data', + colorMode: 'None', + }, + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + index: '196d783b-3779-4c39-898e-6606fe633d05', + type: 'custom', + disabled: false, + negate: false, + alias: null, + key: 'query', + value: + '{"bool":{"should":[{"exists":{"field":"dns.question.name"}},{"term":{"suricata.eve.dns.type":{"value":"query"}}},{"exists":{"field":"zeek.dns.query"}}],"minimum_should_match":1}}', + }, + $state: { + store: 'appState', + }, + query: { + bool: { + should: [ + { + exists: { + field: 'dns.question.name', + }, + }, + { + term: { + 'suricata.eve.dns.type': { + value: 'query', + }, + }, + }, + { + exists: { + field: 'zeek.dns.query', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + 'cea37c70-8f91-43bf-b9fe-72d8c049f6a3': { + columns: { + '0374e520-eae0-4ac1-bcfe-37565e7fc9e3': { + label: 'DNS', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + customLabel: true, + }, + }, + columnOrder: ['0374e520-eae0-4ac1-bcfe-37565e7fc9e3'], + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-cea37c70-8f91-43bf-b9fe-72d8c049f6a3', + }, + { + type: 'index-pattern', + name: '196d783b-3779-4c39-898e-6606fe633d05', + id: '{dataViewId}', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_network_events.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_network_events.ts new file mode 100644 index 00000000000000..a51b8ee0ac66de --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_network_events.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const kpiNetworkEventsLensAttributes: LensAttributes = { + title: '[Network] Network events', + description: '', + visualizationType: 'lnsMetric', + state: { + visualization: { + layerId: 'eaadfec7-deaa-4aeb-a403-3b4e516416d2', + accessor: '370ebd07-5ce0-4f46-a847-0e363c50d037', + layerType: 'data', + }, + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + index: 'security-solution-default', + alias: null, + negate: false, + disabled: false, + type: 'exists', + key: 'source.ip', + value: 'exists', + }, + query: { + exists: { + field: 'source.ip', + }, + }, + $state: { + store: 'appState', + }, + }, + { + meta: { + index: 'security-solution-default', + alias: null, + negate: false, + disabled: false, + type: 'exists', + key: 'destination.ip', + value: 'exists', + }, + query: { + exists: { + field: 'destination.ip', + }, + }, + $state: { + store: 'appState', + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + 'eaadfec7-deaa-4aeb-a403-3b4e516416d2': { + columns: { + '370ebd07-5ce0-4f46-a847-0e363c50d037': { + label: ' ', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + customLabel: true, + }, + }, + columnOrder: ['370ebd07-5ce0-4f46-a847-0e363c50d037'], + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-eaadfec7-deaa-4aeb-a403-3b4e516416d2', + }, + { + type: 'index-pattern', + name: '861af17d-be25-45a3-a82d-d6e697b76e51', + id: '{dataViewId}', + }, + { + type: 'index-pattern', + name: '09617767-f732-410e-af53-bebcbd0bf4b9', + id: '{dataViewId}', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes.ts new file mode 100644 index 00000000000000..2e250f6fe3e5b8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { LensAttributes } from '../../types'; + +export const kpiTlsHandshakesLensAttributes: LensAttributes = { + title: '[Network] TLS handshakes', + description: '', + visualizationType: 'lnsMetric', + state: { + visualization: { + layerId: '1f48a633-8eee-45ae-9471-861227e9ca03', + accessor: '21052b6b-5504-4084-a2e2-c17f772345cf', + layerType: 'data', + }, + query: { + query: + '(source.ip: * or destination.ip: *) and (tls.version: * or suricata.eve.tls.version: * or zeek.ssl.version: * )', + language: 'kuery', + }, + filters: [ + { + meta: { + index: '32ee22d9-2e77-4aee-8073-87750e92c3ee', + type: 'custom', + disabled: false, + negate: false, + alias: null, + key: 'query', + value: + '{"bool":{"should":[{"exists":{"field":"source.ip"}},{"exists":{"field":"destination.ip"}}],"minimum_should_match":1}}', + }, + $state: { + store: 'appState', + }, + query: { + bool: { + should: [ + { + exists: { + field: 'source.ip', + }, + }, + { + exists: { + field: 'destination.ip', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + { + meta: { + index: '1e93f984-9374-4755-a198-de57751533c6', + type: 'custom', + disabled: false, + negate: false, + alias: null, + key: 'query', + value: + '{"bool":{"should":[{"exists":{"field":"tls.version"}},{"exists":{"field":"suricata.eve.tls.version"}},{"exists":{"field":"zeek.ssl.version"}}],"minimum_should_match":1}}', + }, + $state: { + store: 'appState', + }, + query: { + bool: { + should: [ + { + exists: { + field: 'tls.version', + }, + }, + { + exists: { + field: 'suricata.eve.tls.version', + }, + }, + { + exists: { + field: 'zeek.ssl.version', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + '1f48a633-8eee-45ae-9471-861227e9ca03': { + columns: { + '21052b6b-5504-4084-a2e2-c17f772345cf': { + label: ' ', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + customLabel: true, + }, + }, + columnOrder: ['21052b6b-5504-4084-a2e2-c17f772345cf'], + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-1f48a633-8eee-45ae-9471-861227e9ca03', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids.ts new file mode 100644 index 00000000000000..cb75ddef54baef --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAttributes } from '../../types'; + +export const kpiUniqueFlowIdsLensAttributes: LensAttributes = { + title: '[Network] Unique flow IDs', + description: '', + visualizationType: 'lnsMetric', + state: { + visualization: { + layerId: '5d46d48f-6ce8-46be-a797-17ad50642564', + accessor: 'a27f3503-9c73-4fc1-86bb-12461dae4b70', + layerType: 'data', + }, + query: { + query: 'source.ip: * or destination.ip: * ', + language: 'kuery', + }, + filters: [ + { + meta: { + index: 'c01edc8a-90ce-4d49-95f0-76954a034eb2', + type: 'custom', + disabled: false, + negate: false, + alias: null, + key: 'query', + value: + '{"bool":{"should":[{"exists":{"field":"source.ip"}},{"exists":{"field":"destination.ip"}}],"minimum_should_match":1}}', + }, + $state: { + store: 'appState', + }, + query: { + bool: { + should: [ + { + exists: { + field: 'source.ip', + }, + }, + { + exists: { + field: 'destination.ip', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + ], + datasourceStates: { + indexpattern: { + layers: { + '5d46d48f-6ce8-46be-a797-17ad50642564': { + columns: { + 'a27f3503-9c73-4fc1-86bb-12461dae4b70': { + label: ' ', + dataType: 'number', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'network.community_id', + isBucketed: false, + customLabel: true, + }, + }, + columnOrder: ['a27f3503-9c73-4fc1-86bb-12461dae4b70'], + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-5d46d48f-6ce8-46be-a797-17ad50642564', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_area.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_area.ts new file mode 100644 index 00000000000000..89104df5d72be6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_area.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { LensAttributes } from '../../types'; + +export const kpiUniquePrivateIpsAreaLensAttributes: LensAttributes = { + title: '[Network] Unique private IPs - area chart', + description: '', + visualizationType: 'lnsXY', + state: { + visualization: { + legend: { + isVisible: false, + position: 'right', + showSingleSeries: false, + }, + valueLabels: 'hide', + fittingFunction: 'None', + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + axisTitlesVisibilitySettings: { + x: false, + yLeft: false, + yRight: true, + }, + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + preferredSeriesType: 'area', + layers: [ + { + layerId: '38aa6532-6bf9-4c8f-b2a6-da8d32f7d0d7', + seriesType: 'area', + accessors: ['5f317308-cfbb-4ee5-bfb9-07653184fabf'], + layerType: 'data', + xAccessor: '662cd5e5-82bf-4325-a703-273f84b97e09', + yConfig: [ + { + forAccessor: '5f317308-cfbb-4ee5-bfb9-07653184fabf', + color: '#d36186', + }, + ], + }, + { + layerId: '72dc4b99-b07d-4dc9-958b-081d259e11fa', + seriesType: 'area', + accessors: ['ac1eb80c-ddde-46c4-a90c-400261926762'], + layerType: 'data', + xAccessor: '36444b8c-7e10-4069-8298-6c1b46912be2', + yConfig: [ + { + forAccessor: 'ac1eb80c-ddde-46c4-a90c-400261926762', + color: '#9170b8', + }, + ], + }, + ], + }, + query: { + query: '', + language: 'kuery', + }, + filters: [], + datasourceStates: { + indexpattern: { + layers: { + '38aa6532-6bf9-4c8f-b2a6-da8d32f7d0d7': { + columns: { + '662cd5e5-82bf-4325-a703-273f84b97e09': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { + interval: 'auto', + }, + }, + '5f317308-cfbb-4ee5-bfb9-07653184fabf': { + label: 'Src.', + dataType: 'number', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'source.ip', + isBucketed: false, + customLabel: true, + filter: { + query: + '"source.ip": "10.0.0.0/8" or "source.ip": "192.168.0.0/16" or "source.ip": "172.16.0.0/12" or "source.ip": "fd00::/8"', + language: 'kuery', + }, + }, + }, + columnOrder: [ + '662cd5e5-82bf-4325-a703-273f84b97e09', + '5f317308-cfbb-4ee5-bfb9-07653184fabf', + ], + incompleteColumns: {}, + }, + '72dc4b99-b07d-4dc9-958b-081d259e11fa': { + columns: { + '36444b8c-7e10-4069-8298-6c1b46912be2': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { + interval: 'auto', + }, + }, + 'ac1eb80c-ddde-46c4-a90c-400261926762': { + label: 'Dest.', + dataType: 'number', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'destination.ip', + isBucketed: false, + filter: { + query: + '"destination.ip": "10.0.0.0/8" or "destination.ip": "192.168.0.0/16" or "destination.ip": "172.16.0.0/12" or "destination.ip": "fd00::/8"', + language: 'kuery', + }, + }, + }, + columnOrder: [ + '36444b8c-7e10-4069-8298-6c1b46912be2', + 'ac1eb80c-ddde-46c4-a90c-400261926762', + ], + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-38aa6532-6bf9-4c8f-b2a6-da8d32f7d0d7', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-72dc4b99-b07d-4dc9-958b-081d259e11fa', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_bar.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_bar.ts new file mode 100644 index 00000000000000..4bbc1e1510dbe9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_bar.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SOURCE_CHART_LABEL, DESTINATION_CHART_LABEL } from '../../translations'; +import { LensAttributes } from '../../types'; + +export const kpiUniquePrivateIpsBarLensAttributes: LensAttributes = { + title: '[Network] Unique private IPs - bar chart', + description: '', + visualizationType: 'lnsXY', + state: { + visualization: { + legend: { + isVisible: false, + position: 'right', + showSingleSeries: false, + }, + valueLabels: 'hide', + fittingFunction: 'None', + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + axisTitlesVisibilitySettings: { + x: false, + yLeft: false, + yRight: true, + }, + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + preferredSeriesType: 'bar_horizontal_stacked', + layers: [ + { + layerId: 'e406bf4f-942b-41ac-b516-edb5cef06ec8', + accessors: ['5acd4c9d-dc3b-4b21-9632-e4407944c36d'], + position: 'top', + seriesType: 'bar_horizontal_stacked', + showGridlines: false, + layerType: 'data', + yConfig: [ + { + forAccessor: '5acd4c9d-dc3b-4b21-9632-e4407944c36d', + color: '#d36186', + }, + ], + xAccessor: 'd9c438c5-f776-4436-9d20-d62dc8c03be8', + }, + { + layerId: '38aa6532-6bf9-4c8f-b2a6-da8d32f7d0d7', + seriesType: 'bar_horizontal_stacked', + accessors: ['d27e0966-daf9-41f4-9033-230cf1e76dc9'], + layerType: 'data', + yConfig: [ + { + forAccessor: 'd27e0966-daf9-41f4-9033-230cf1e76dc9', + color: '#9170b8', + }, + ], + xAccessor: '4607c585-3af3-43b9-804f-e49b27796d79', + }, + ], + }, + query: { + query: '', + language: 'kuery', + }, + filters: [], + datasourceStates: { + indexpattern: { + layers: { + 'e406bf4f-942b-41ac-b516-edb5cef06ec8': { + columns: { + '5acd4c9d-dc3b-4b21-9632-e4407944c36d': { + label: SOURCE_CHART_LABEL, + dataType: 'number', + isBucketed: false, + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'source.ip', + filter: { + query: + 'source.ip: "10.0.0.0/8" or source.ip: "192.168.0.0/16" or source.ip: "172.16.0.0/12" or source.ip: "fd00::/8"', + language: 'kuery', + }, + }, + 'd9c438c5-f776-4436-9d20-d62dc8c03be8': { + label: 'Filters', + dataType: 'string', + operationType: 'filters', + scale: 'ordinal', + isBucketed: true, + params: { + filters: [ + { + input: { + query: '', + language: 'kuery', + }, + label: SOURCE_CHART_LABEL, + }, + ], + }, + }, + }, + columnOrder: [ + 'd9c438c5-f776-4436-9d20-d62dc8c03be8', + '5acd4c9d-dc3b-4b21-9632-e4407944c36d', + ], + incompleteColumns: {}, + }, + '38aa6532-6bf9-4c8f-b2a6-da8d32f7d0d7': { + columns: { + 'd27e0966-daf9-41f4-9033-230cf1e76dc9': { + label: DESTINATION_CHART_LABEL, + dataType: 'number', + isBucketed: false, + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'destination.ip', + filter: { + query: + '"destination.ip": "10.0.0.0/8" or "destination.ip": "192.168.0.0/16" or "destination.ip": "172.16.0.0/12" or "destination.ip": "fd00::/8"', + language: 'kuery', + }, + }, + '4607c585-3af3-43b9-804f-e49b27796d79': { + label: 'Filters', + dataType: 'string', + operationType: 'filters', + scale: 'ordinal', + isBucketed: true, + params: { + filters: [ + { + input: { + query: '', + language: 'kuery', + }, + label: DESTINATION_CHART_LABEL, + }, + ], + }, + }, + }, + columnOrder: [ + '4607c585-3af3-43b9-804f-e49b27796d79', + 'd27e0966-daf9-41f4-9033-230cf1e76dc9', + ], + incompleteColumns: {}, + }, + }, + }, + }, + }, + references: [ + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-e406bf4f-942b-41ac-b516-edb5cef06ec8', + }, + { + type: 'index-pattern', + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-38aa6532-6bf9-4c8f-b2a6-da8d32f7d0d7', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_destination_metric.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_destination_metric.ts new file mode 100644 index 00000000000000..b5664d8cb8348d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_destination_metric.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DESTINATION_CHART_LABEL } from '../../translations'; +import { LensAttributes } from '../../types'; + +export const kpiUniquePrivateIpsDestinationMetricLensAttributes: LensAttributes = { + description: '', + state: { + datasourceStates: { + indexpattern: { + layers: { + 'cea37c70-8f91-43bf-b9fe-72d8c049f6a3': { + columnOrder: ['bd17c23e-4f83-4108-8005-2669170d064b'], + columns: { + 'bd17c23e-4f83-4108-8005-2669170d064b': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: DESTINATION_CHART_LABEL, + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'destination.ip', + filter: { + language: 'kuery', + query: + '"destination.ip": "10.0.0.0/8" or "destination.ip": "192.168.0.0/16" or "destination.ip": "172.16.0.0/12" or "destination.ip": "fd00::/8"', + }, + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: '', + }, + visualization: { + accessor: 'bd17c23e-4f83-4108-8005-2669170d064b', + layerId: 'cea37c70-8f91-43bf-b9fe-72d8c049f6a3', + layerType: 'data', + }, + }, + title: '[Network] Unique private IPs - destination metric', + visualizationType: 'lnsMetric', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-cea37c70-8f91-43bf-b9fe-72d8c049f6a3', + type: 'index-pattern', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_source_metric.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_source_metric.ts new file mode 100644 index 00000000000000..4ddeacbe7fa4c6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_source_metric.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { SOURCE_CHART_LABEL } from '../../translations'; +import { LensAttributes } from '../../types'; + +export const kpiUniquePrivateIpsSourceMetricLensAttributes: LensAttributes = { + description: '', + state: { + datasourceStates: { + indexpattern: { + layers: { + 'cea37c70-8f91-43bf-b9fe-72d8c049f6a3': { + columnOrder: ['bd17c23e-4f83-4108-8005-2669170d064b'], + columns: { + 'bd17c23e-4f83-4108-8005-2669170d064b': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: SOURCE_CHART_LABEL, + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'source.ip', + filter: { + query: + 'source.ip: "10.0.0.0/8" or source.ip: "192.168.0.0/16" or source.ip: "172.16.0.0/12" or source.ip: "fd00::/8"', + language: 'kuery', + }, + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: '', + }, + visualization: { + accessor: 'bd17c23e-4f83-4108-8005-2669170d064b', + layerId: 'cea37c70-8f91-43bf-b9fe-72d8c049f6a3', + layerType: 'data', + }, + }, + title: '[Network] Unique private IPs - source metric', + visualizationType: 'lnsMetric', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-cea37c70-8f91-43bf-b9fe-72d8c049f6a3', + type: 'index-pattern', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/readme.md b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/readme.md new file mode 100644 index 00000000000000..f9620a2a42eeff --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/readme.md @@ -0,0 +1,19 @@ + +# Steps to generate Lens attributes: +All the files in the folder were exported from Lens. These Lens attributes allow the charts implemented by Security Solution (Not with Lens Embeddable) on Host and Network pages to share across the app. + + +Here are the steps of how to generate them: + +1. Launch Kibana, go to `Visualize Library`, `Create visualization`, and select `Lens` to enter the editor. +2. Create the visualization and save it. +3. Go to `Stack Management` > `Saved Objects`, tick the box of the visualization you just created, and click `Export` +4. Create a new file in this folder with the attributes below from the exported file: + - description + - state + - title + - visualizationType + - references + + Note: `id` under `references` will eventually be replaced according to selected data view id on Security Solution's page + diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/translations.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/translations.ts new file mode 100644 index 00000000000000..9707fd39d9322e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/translations.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const MORE_ACTIONS = i18n.translate( + 'xpack.securitySolution.visualizationActions.moreActions', + { + defaultMessage: 'More actions', + } +); + +export const INSPECT = i18n.translate('xpack.securitySolution.visualizationActions.inspect', { + defaultMessage: 'Inspect', +}); + +export const OPEN_IN_LENS = i18n.translate( + 'xpack.securitySolution.visualizationActions.openInLens', + { + defaultMessage: 'Open in Lens', + } +); + +export const ADD_TO_NEW_CASE = i18n.translate( + 'xpack.securitySolution.visualizationActions.addToNewCase', + { + defaultMessage: 'Add to new case', + } +); + +export const ADD_TO_EXISTING_CASE = i18n.translate( + 'xpack.securitySolution.visualizationActions.addToExistingCase', + { + defaultMessage: 'Add to existing case', + } +); + +export const SOURCE_CHART_LABEL = i18n.translate( + 'xpack.securitySolution.visualizationActions.uniqueIps.sourceChartLabel', + { + defaultMessage: 'Src.', + } +); + +export const DESTINATION_CHART_LABEL = i18n.translate( + 'xpack.securitySolution.visualizationActions.uniqueIps.destinationChartLabel', + { + defaultMessage: 'Dest.', + } +); + +export const SUCCESS_CHART_LABEL = i18n.translate( + 'xpack.securitySolution.visualizationActions.userAuthentications.successChartLabel', + { + defaultMessage: 'Succ.', + } +); + +export const FAIL_CHART_LABEL = i18n.translate( + 'xpack.securitySolution.visualizationActions.userAuthentications.failChartLabel', + { + defaultMessage: 'Fail', + } +); + +export const SUCCESS_UNIT_LABEL = i18n.translate( + 'xpack.securitySolution.visualizationActions.userAuthentications.successUnitLabel', + { + defaultMessage: 'success', + } +); + +export const FAIL_UNIT_LABEL = i18n.translate( + 'xpack.securitySolution.visualizationActions.userAuthentications.failUnitLabel', + { + defaultMessage: 'fail', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/types.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/types.ts new file mode 100644 index 00000000000000..9fc193e71b40d4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/types.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypedLensByValueInput } from '../../../../../lens/public'; +import { InputsModelId } from '../../store/inputs/constants'; + +export type LensAttributes = TypedLensByValueInput['attributes']; +export type GetLensAttributes = (stackByField?: string) => LensAttributes; + +export interface VisualizationActionsProps { + className?: string; + getLensAttributes?: GetLensAttributes; + inputId?: InputsModelId; + inspectIndex?: number; + isInspectButtonDisabled?: boolean; + isMultipleQuery?: boolean; + lensAttributes?: LensAttributes | null; + onCloseInspect?: () => void; + queryId: string; + stackByField?: string; + timerange: { from: string; to: string }; + title: React.ReactNode; +} diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.test.tsx new file mode 100644 index 00000000000000..a1831a7fccde78 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.test.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { mockCasesContract } from '../../../../../cases/public/mocks'; +import { useKibana } from '../../lib/kibana'; +import { kpiHostMetricLensAttributes } from './lens_attributes/hosts/kpi_host_metric'; +import { useAddToExistingCase } from './use_add_to_existing_case'; + +jest.mock('../../lib/kibana/kibana_react'); + +describe('', () => { + const mockCases = mockCasesContract(); + const mockOnAddToCaseClicked = jest.fn(); + const timeRange = { + from: '2022-03-06T16:00:00.000Z', + to: '2022-03-07T15:59:59.999Z', + }; + const owner = 'securitySolution'; + const type = 'user'; + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: mockCases, + }, + }); + }); + + it('getUseCasesAddToExistingCaseModal with attachments', () => { + const { result } = renderHook(() => + useAddToExistingCase({ + lensAttributes: kpiHostMetricLensAttributes, + timeRange, + userCanCrud: true, + onAddToCaseClicked: mockOnAddToCaseClicked, + }) + ); + expect(mockCases.hooks.getUseCasesAddToExistingCaseModal).toHaveBeenCalledWith({ + attachments: [ + { + comment: `!{lens${JSON.stringify({ + timeRange, + attributes: kpiHostMetricLensAttributes, + })}}`, + owner, + type, + }, + ], + onClose: mockOnAddToCaseClicked, + }); + expect(result.current.disabled).toEqual(false); + }); + + it("button disalbled if user Can't Crud", () => { + const { result } = renderHook(() => + useAddToExistingCase({ + lensAttributes: kpiHostMetricLensAttributes, + timeRange, + userCanCrud: false, + onAddToCaseClicked: mockOnAddToCaseClicked, + }) + ); + expect(result.current.disabled).toEqual(true); + }); + + it('button disalbled if no lensAttributes', () => { + const { result } = renderHook(() => + useAddToExistingCase({ + lensAttributes: null, + timeRange, + userCanCrud: true, + onAddToCaseClicked: mockOnAddToCaseClicked, + }) + ); + expect(result.current.disabled).toEqual(true); + }); + + it('button disalbled if no timeRange', () => { + const { result } = renderHook(() => + useAddToExistingCase({ + lensAttributes: kpiHostMetricLensAttributes, + timeRange: null, + userCanCrud: true, + onAddToCaseClicked: mockOnAddToCaseClicked, + }) + ); + expect(result.current.disabled).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx new file mode 100644 index 00000000000000..5a4ac6dd934e99 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useCallback, useMemo } from 'react'; +import { CommentType } from '../../../../../cases/common'; + +import { APP_ID } from '../../../../common/constants'; +import { useKibana } from '../../lib/kibana/kibana_react'; + +import { LensAttributes } from './types'; + +const owner = APP_ID; + +export const useAddToExistingCase = ({ + onAddToCaseClicked, + lensAttributes, + timeRange, + userCanCrud, +}: { + onAddToCaseClicked?: () => void; + lensAttributes: LensAttributes | null; + timeRange: { from: string; to: string } | null; + userCanCrud: boolean; +}) => { + const { cases } = useKibana().services; + const attachments = useMemo(() => { + return [ + { + comment: `!{lens${JSON.stringify({ + timeRange, + attributes: lensAttributes, + })}}`, + owner, + type: CommentType.user as const, + }, + ]; + }, [lensAttributes, timeRange]); + + const selectCaseModal = cases.hooks.getUseCasesAddToExistingCaseModal({ + attachments, + onClose: onAddToCaseClicked, + }); + + const onAddToExistingCaseClicked = useCallback(() => { + if (onAddToCaseClicked) { + onAddToCaseClicked(); + } + selectCaseModal.open(); + }, [onAddToCaseClicked, selectCaseModal]); + + return { + onAddToExistingCaseClicked, + disabled: lensAttributes == null || timeRange == null || !userCanCrud, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx new file mode 100644 index 00000000000000..0627262eec438d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { mockCasesContract } from '../../../../../cases/public/mocks'; +import { useKibana } from '../../lib/kibana'; +import { kpiHostMetricLensAttributes } from './lens_attributes/hosts/kpi_host_metric'; +import { useAddToNewCase } from './use_add_to_new_case'; + +jest.mock('../../lib/kibana/kibana_react'); + +describe('', () => { + const mockCases = mockCasesContract(); + const timeRange = { + from: '2022-03-06T16:00:00.000Z', + to: '2022-03-07T15:59:59.999Z', + }; + const owner = 'securitySolution'; + const type = 'user'; + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: mockCases, + }, + }); + }); + + it('getUseCasesAddToNewCaseFlyout with attachments', () => { + const { result } = renderHook(() => + useAddToNewCase({ + lensAttributes: kpiHostMetricLensAttributes, + timeRange, + userCanCrud: true, + }) + ); + expect(mockCases.hooks.getUseCasesAddToNewCaseFlyout).toHaveBeenCalledWith({ + attachments: [ + { + comment: `!{lens${JSON.stringify({ + timeRange, + attributes: kpiHostMetricLensAttributes, + })}}`, + owner, + type, + }, + ], + }); + expect(result.current.disabled).toEqual(false); + }); + + it("button disalbled if user Can't Crud", () => { + const { result } = renderHook(() => + useAddToNewCase({ + lensAttributes: kpiHostMetricLensAttributes, + timeRange, + userCanCrud: false, + }) + ); + expect(result.current.disabled).toEqual(true); + }); + + it('button disalbled if no lensAttributes', () => { + const { result } = renderHook(() => + useAddToNewCase({ + lensAttributes: null, + timeRange, + userCanCrud: true, + }) + ); + expect(result.current.disabled).toEqual(true); + }); + + it('button disalbled if no timeRange', () => { + const { result } = renderHook(() => + useAddToNewCase({ + lensAttributes: kpiHostMetricLensAttributes, + timeRange: null, + userCanCrud: true, + }) + ); + expect(result.current.disabled).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx new file mode 100644 index 00000000000000..854894e81c48c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useCallback, useMemo } from 'react'; + +import { CommentType } from '../../../../../cases/common'; + +import { APP_ID } from '../../../../common/constants'; +import { useKibana } from '../../lib/kibana/kibana_react'; + +import { LensAttributes } from './types'; + +export interface UseAddToNewCaseProps { + onClick?: () => void; + timeRange: { from: string; to: string } | null; + lensAttributes: LensAttributes | null; + userCanCrud: boolean; +} + +const owner = APP_ID; + +export const useAddToNewCase = ({ + onClick, + timeRange, + lensAttributes, + userCanCrud, +}: UseAddToNewCaseProps) => { + const { cases } = useKibana().services; + const attachments = useMemo(() => { + return [ + { + comment: `!{lens${JSON.stringify({ + timeRange, + attributes: lensAttributes, + })}}`, + owner, + type: CommentType.user as const, + }, + ]; + }, [lensAttributes, timeRange]); + + const createCaseFlyout = cases.hooks.getUseCasesAddToNewCaseFlyout({ + attachments, + }); + + const onAddToNewCaseClicked = useCallback(() => { + if (onClick) { + onClick(); + } + + createCaseFlyout.open(); + }, [createCaseFlyout, onClick]); + + return { + onAddToNewCaseClicked, + disabled: lensAttributes == null || timeRange == null || !userCanCrud, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx new file mode 100644 index 00000000000000..72257a76d43c83 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { cloneDeep } from 'lodash/fp'; + +import { + TestProviders, + mockGlobalState, + SUB_PLUGINS_REDUCER, + kibanaObservable, + createSecuritySolutionStorageMock, +} from '../../mock'; +import { getExternalAlertLensAttributes } from './lens_attributes/common/external_alert'; +import { useLensAttributes } from './use_lens_attributes'; +import { filterHostExternalAlertData, getHostDetailsPageFilter, getIndexFilters } from './utils'; +import { createStore, State } from '../../store'; + +jest.mock('../../containers/sourcerer', () => ({ + useSourcererDataView: jest.fn().mockReturnValue({ + selectedPatterns: ['auditbeat-*'], + dataViewId: 'security-solution-default', + }), +})); + +jest.mock('../../utils/route/use_route_spy', () => ({ + useRouteSpy: jest.fn().mockReturnValue([ + { + detailName: 'mockHost', + pageName: 'hosts', + tabName: 'externalAlerts', + }, + ]), +})); + +describe('useLensAttributes', () => { + const state: State = mockGlobalState; + const { storage } = createSecuritySolutionStorageMock(); + const queryFromSearchBar = { + query: 'host.name: *', + language: 'kql', + }; + + const filterFromSearchBar = [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'host.id', + params: { + query: '123', + }, + }, + query: { + match_phrase: { + 'host.id': '123', + }, + }, + }, + ]; + let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + beforeEach(() => { + const myState = cloneDeep(state); + myState.inputs = { + ...myState.inputs, + global: { + ...myState.inputs.global, + query: queryFromSearchBar, + filters: filterFromSearchBar, + }, + }; + store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + }); + + it('should should add query', () => { + const wrapper = ({ children }: { children: React.ReactElement }) => ( + {children} + ); + const { result } = renderHook( + () => + useLensAttributes({ + getLensAttributes: getExternalAlertLensAttributes, + stackByField: 'event.dataset', + }), + { wrapper } + ); + + expect(result?.current?.state.query).toEqual({ query: 'host.name: *', language: 'kql' }); + }); + + it('should should add filters', () => { + const wrapper = ({ children }: { children: React.ReactElement }) => ( + {children} + ); + const { result } = renderHook( + () => + useLensAttributes({ + getLensAttributes: getExternalAlertLensAttributes, + stackByField: 'event.dataset', + }), + { wrapper } + ); + + expect(result?.current?.state.filters).toEqual([ + ...getExternalAlertLensAttributes().state.filters, + ...filterFromSearchBar, + ...getHostDetailsPageFilter('mockHost'), + ...filterHostExternalAlertData, + ...getIndexFilters(['auditbeat-*']), + ]); + }); + + it('should should add data view id to references', () => { + const wrapper = ({ children }: { children: React.ReactElement }) => ( + {children} + ); + const { result } = renderHook( + () => + useLensAttributes({ + getLensAttributes: getExternalAlertLensAttributes, + stackByField: 'event.dataset', + }), + { wrapper } + ); + + expect(result?.current?.references).toEqual([ + { + type: 'index-pattern', + id: 'security-solution-default', + name: 'indexpattern-datasource-current-indexpattern', + }, + { + type: 'index-pattern', + id: 'security-solution-default', + name: 'indexpattern-datasource-layer-a3c54471-615f-4ff9-9fda-69b5b2ea3eef', + }, + { + type: 'index-pattern', + name: '723c4653-681b-4105-956e-abef287bf025', + id: 'security-solution-default', + }, + { + type: 'index-pattern', + name: 'a04472fc-94a3-4b8d-ae05-9d30ea8fbd6a', + id: 'security-solution-default', + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx new file mode 100644 index 00000000000000..123cff112456cd --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { SecurityPageName } from '../../../../common/constants'; +import { NetworkRouteType } from '../../../network/pages/navigation/types'; +import { useSourcererDataView } from '../../containers/sourcerer'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { inputsSelectors } from '../../store'; +import { useRouteSpy } from '../../utils/route/use_route_spy'; +import { LensAttributes, GetLensAttributes } from './types'; +import { + getHostDetailsPageFilter, + filterNetworkExternalAlertData, + filterHostExternalAlertData, + getIndexFilters, +} from './utils'; + +export const useLensAttributes = ({ + lensAttributes, + getLensAttributes, + stackByField, +}: { + lensAttributes?: LensAttributes | null; + getLensAttributes?: GetLensAttributes; + stackByField?: string; +}): LensAttributes | null => { + const { selectedPatterns, dataViewId } = useSourcererDataView(); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + const [{ detailName, pageName, tabName }] = useRouteSpy(); + + const tabsFilters = useMemo(() => { + if (pageName === SecurityPageName.hosts && tabName === 'externalAlerts') { + return filterHostExternalAlertData; + } + + if (pageName === SecurityPageName.network && tabName === NetworkRouteType.alerts) { + return filterNetworkExternalAlertData; + } + + return []; + }, [pageName, tabName]); + + const pageFilters = useMemo(() => { + if (pageName === SecurityPageName.hosts && detailName != null) { + return getHostDetailsPageFilter(detailName); + } + return []; + }, [detailName, pageName]); + + const indexFilters = useMemo(() => getIndexFilters(selectedPatterns), [selectedPatterns]); + + const lensAttrsWithInjectedData = useMemo(() => { + if (lensAttributes == null && (getLensAttributes == null || stackByField == null)) { + return null; + } + const attrs: LensAttributes = + lensAttributes ?? + ((getLensAttributes && stackByField && getLensAttributes(stackByField)) as LensAttributes); + + return { + ...attrs, + state: { + ...attrs.state, + query, + filters: [ + ...attrs.state.filters, + ...filters, + ...pageFilters, + ...tabsFilters, + ...indexFilters, + ], + }, + references: attrs.references.map((ref: { id: string; name: string; type: string }) => ({ + ...ref, + id: dataViewId, + })), + } as LensAttributes; + }, [ + lensAttributes, + getLensAttributes, + stackByField, + query, + filters, + pageFilters, + tabsFilters, + indexFilters, + dataViewId, + ]); + + return lensAttrsWithInjectedData; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/utils.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/utils.ts new file mode 100644 index 00000000000000..9a6df2ab6ab998 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/utils.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Filter } from '@kbn/es-query'; + +export const getHostDetailsPageFilter = (hostName?: string): Filter[] => + hostName + ? [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'host.name', + params: { + query: hostName, + }, + }, + query: { + match_phrase: { + 'host.name': hostName, + }, + }, + }, + ] + : []; + +export const filterHostExternalAlertData: Filter[] = [ + { + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + exists: { + field: 'host.name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + meta: { + alias: '', + disabled: false, + key: 'bool', + negate: false, + type: 'custom', + value: + '{"query": {"bool": {"filter": [{"bool": {"should": [{"exists": {"field": "host.name"}}],"minimum_should_match": 1}}]}}}', + }, + }, +]; + +export const filterNetworkExternalAlertData: Filter[] = [ + { + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + bool: { + should: [ + { + exists: { + field: 'source.ip', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + exists: { + field: 'destination.ip', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + meta: { + alias: '', + disabled: false, + key: 'bool', + negate: false, + type: 'custom', + value: + '{"bool":{"filter":[{"bool":{"should":[{"bool":{"should":[{"exists":{"field": "source.ip"}}],"minimum_should_match":1}},{"bool":{"should":[{"exists":{"field": "destination.ip"}}],"minimum_should_match":1}}],"minimum_should_match":1}}]}}', + }, + }, +]; + +export const getIndexFilters = (selectedPatterns: string[]) => + selectedPatterns.length >= 1 + ? [ + { + meta: { + type: 'phrases', + key: '_index', + params: selectedPatterns, + alias: null, + negate: false, + disabled: false, + }, + query: { + bool: { + should: selectedPatterns.map((selectedPattern) => ({ + match_phrase: { _index: selectedPattern }, + })), + minimum_should_match: 1, + }, + }, + }, + ] + : []; diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/helpers.ts b/x-pack/plugins/security_solution/public/common/store/inputs/helpers.ts index 94e7ff6fbc981f..d2e350027f9057 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/helpers.ts @@ -109,7 +109,14 @@ export const upsertQuery = ({ ] : [ ...state[inputId].queries, - { id, inspect, isInspected: false, loading, refetch, selectedInspectIndex: 0 }, + { + id, + inspect, + isInspected: false, + loading, + refetch, + selectedInspectIndex: 0, + }, ], }, }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx index c05d462226c51e..ce73b1cd07f61b 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx @@ -8,6 +8,10 @@ import React from 'react'; import { StatItems } from '../../../../common/components/stat_items'; +import { kpiUserAuthenticationsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_area'; +import { kpiUserAuthenticationsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_bar'; +import { kpiUserAuthenticationsMetricSuccessLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_metric_success'; +import { kpiUserAuthenticationsMetricFailureLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentication_metric_failure'; import { useHostsKpiAuthentications } from '../../../containers/kpi_hosts/authentications'; import { HostsKpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; @@ -24,6 +28,7 @@ export const fieldsMapping: Readonly = [ value: null, color: HostsKpiChartColors.authenticationsSuccess, icon: 'check', + lensAttributes: kpiUserAuthenticationsMetricSuccessLensAttributes, }, { key: 'authenticationsFailure', @@ -32,11 +37,14 @@ export const fieldsMapping: Readonly = [ value: null, color: HostsKpiChartColors.authenticationsFailure, icon: 'cross', + lensAttributes: kpiUserAuthenticationsMetricFailureLensAttributes, }, ], enableAreaChart: true, enableBarChart: true, description: i18n.USER_AUTHENTICATIONS, + areaChartLensAttributes: kpiUserAuthenticationsAreaLensAttributes, + barChartLensAttributes: kpiUserAuthenticationsBarLensAttributes, }, ]; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx index 206b452d838989..1f120617a36938 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx @@ -19,6 +19,8 @@ import { StatItems, } from '../../../../common/components/stat_items'; import { UpdateDateRange } from '../../../../common/components/charts/common'; +import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; +import { APP_ID } from '../../../../../common/constants'; const kpiWidgetHeight = 247; @@ -40,6 +42,11 @@ interface HostsKpiBaseComponentProps { export const HostsKpiBaseComponent = React.memo( ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { + const { cases } = useKibana().services; + const CasesContext = cases.ui.getCasesContext(); + const userPermissions = useGetUserCasesPermissions(); + const userCanCrud = userPermissions?.crud ?? false; + const statItemsProps: StatItemsProps[] = useKpiMatrixStatus( fieldsMapping, data, @@ -55,9 +62,11 @@ export const HostsKpiBaseComponent = React.memo( return ( - {statItemsProps.map((mappedStatItemProps) => ( - - ))} + + {statItemsProps.map((mappedStatItemProps) => ( + + ))} + ); }, diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx index 71a8d15aceb575..4e73a429fbc1df 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx @@ -8,6 +8,8 @@ import React from 'react'; import { StatItems } from '../../../../common/components/stat_items'; +import { kpiHostAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_area'; +import { kpiHostMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric'; import { useHostsKpiHosts } from '../../../containers/kpi_hosts/hosts'; import { HostsKpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; @@ -22,10 +24,12 @@ export const fieldsMapping: Readonly = [ value: null, color: HostsKpiChartColors.hosts, icon: 'storage', + lensAttributes: kpiHostMetricLensAttributes, }, ], enableAreaChart: true, description: i18n.HOSTS, + areaChartLensAttributes: kpiHostAreaLensAttributes, }, ]; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/translations.ts b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/translations.ts index f97dc80fd9679d..ef289a0aa6aab2 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/translations.ts +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/translations.ts @@ -34,7 +34,7 @@ export const RISKY_HOSTS_TITLE = i18n.translate( export const INSPECT_RISKY_HOSTS = i18n.translate( 'xpack.securitySolution.kpiHosts.riskyHosts.inspectTitle', { - defaultMessage: 'KPI Risky Hosts', + defaultMessage: 'Risky Hosts', } ); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx index ce6a1a71050687..2d95e3c98f4aeb 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx @@ -8,6 +8,10 @@ import React from 'react'; import { StatItems } from '../../../../common/components/stat_items'; +import { kpiUniqueIpsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area'; +import { kpiUniqueIpsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_bar'; +import { kpiUniqueIpsDestinationMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_destination_metric'; +import { kpiUniqueIpsSourceMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_source_metric'; import { useHostsKpiUniqueIps } from '../../../containers/kpi_hosts/unique_ips'; import { HostsKpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; @@ -24,6 +28,7 @@ export const fieldsMapping: Readonly = [ value: null, color: HostsKpiChartColors.uniqueSourceIps, icon: 'visMapCoordinate', + lensAttributes: kpiUniqueIpsSourceMetricLensAttributes, }, { key: 'uniqueDestinationIps', @@ -32,11 +37,14 @@ export const fieldsMapping: Readonly = [ value: null, color: HostsKpiChartColors.uniqueDestinationIps, icon: 'visMapCoordinate', + lensAttributes: kpiUniqueIpsDestinationMetricLensAttributes, }, ], enableAreaChart: true, enableBarChart: true, description: i18n.UNIQUE_IPS, + areaChartLensAttributes: kpiUniqueIpsAreaLensAttributes, + barChartLensAttributes: kpiUniqueIpsBarLensAttributes, }, ]; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx index f2b0a64cbe60c5..f0ac78e3950d48 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx @@ -18,16 +18,25 @@ import { type } from './utils'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { getHostDetailsPageFilters } from './helpers'; import { HostsTableType } from '../../store/model'; +import { mockCasesContract } from '../../../../../cases/public/mocks'; -jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../common/lib/kibana'); -jest.mock('../../../common/components/url_state/normalize_time_range.ts'); + return { + ...original, + useKibana: () => ({ + ...original.useKibana(), + services: { + ...original.useKibana().services, + cases: mockCasesContract(), + timelines: { getTGrid: jest.fn().mockReturnValue(() => <>) }, + }, + }), + }; +}); -jest.mock('../../../common/lib/kibana/hooks', () => ({ - useNavigateTo: () => ({ - navigateTo: jest.fn(), - }), -})); +jest.mock('../../../common/components/url_state/normalize_time_range.ts'); jest.mock('../../../common/containers/source', () => ({ useFetchIndex: () => [false, { indicesExist: true, indexPatterns: mockIndexPattern }], @@ -54,6 +63,9 @@ jest.mock('../../../common/components/query_bar', () => ({ const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; jest.mock('use-resize-observer/polyfilled'); mockUseResizeObserver.mockImplementation(() => ({})); +jest.mock('../../../common/components/visualization_actions', () => ({ + VisualizationActions: jest.fn(() =>
), +})); describe('body', () => { const scenariosMap = { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx index 2305c2faad8adb..86dae3780e1aed 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx @@ -24,6 +24,7 @@ import { State, createStore } from '../../common/store'; import { Hosts } from './hosts'; import { HostsTabs } from './hosts_tabs'; import { useSourcererDataView } from '../../common/containers/sourcerer'; +import { mockCasesContract } from '../../../../cases/public/mocks'; jest.mock('../../common/containers/sourcerer'); @@ -35,6 +36,25 @@ jest.mock('../../common/components/search_bar', () => ({ jest.mock('../../common/components/query_bar', () => ({ QueryBar: () => null, })); +jest.mock('../../common/components/visualization_actions', () => ({ + VisualizationActions: jest.fn(() =>
), +})); + +jest.mock('../../common/lib/kibana', () => { + const original = jest.requireActual('../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + cases: { + ...mockCasesContract(), + }, + }, + }), + }; +}); type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index f850ff4c630266..5ca7aa1f1dd497 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -40,7 +40,6 @@ import { Display } from './display'; import { HostsTabs } from './hosts_tabs'; import { navTabsHosts } from './nav_tabs'; import * as i18n from './translations'; -import { filterHostData } from './navigation'; import { hostsModel, hostsSelectors } from '../store'; import { generateSeverityFilter } from '../store/helpers'; import { HostsTableType } from '../store/model'; @@ -56,6 +55,7 @@ import { useDeepEqualSelector, useShallowEqualSelector } from '../../common/hook import { useInvalidFilterQuery } from '../../common/hooks/use_invalid_filter_query'; import { ID } from '../containers/hosts'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { filterHostExternalAlertData } from '../../common/components/visualization_actions/utils'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. @@ -100,13 +100,15 @@ const HostsComponent = () => { const { tabName } = useParams<{ tabName: string }>(); const tabsFilters = React.useMemo(() => { if (tabName === HostsTableType.alerts) { - return filters.length > 0 ? [...filters, ...filterHostData] : filterHostData; + return filters.length > 0 + ? [...filters, ...filterHostExternalAlertData] + : filterHostExternalAlertData; } if (tabName === HostsTableType.risk) { const severityFilter = generateSeverityFilter(severitySelection); - return [...severityFilter, ...filterHostData, ...filters]; + return [...severityFilter, ...filterHostExternalAlertData, ...filters]; } return filters; }, [severitySelection, tabName, filters]); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/alerts_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/alerts_query_tab_body.tsx index 41c0f93760cb7c..548520676184ad 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/alerts_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/alerts_query_tab_body.tsx @@ -7,46 +7,18 @@ import React, { useMemo } from 'react'; -import type { Filter } from '@kbn/es-query'; import { TimelineId } from '../../../../common/types/timeline'; import { AlertsView } from '../../../common/components/alerts_viewer'; +import { filterHostExternalAlertData } from '../../../common/components/visualization_actions/utils'; import { AlertsComponentQueryProps } from './types'; -export const filterHostData: Filter[] = [ - { - query: { - bool: { - filter: [ - { - bool: { - should: [ - { - exists: { - field: 'host.name', - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - }, - }, - meta: { - alias: '', - disabled: false, - key: 'bool', - negate: false, - type: 'custom', - value: - '{"query": {"bool": {"filter": [{"bool": {"should": [{"exists": {"field": "host.name"}}],"minimum_should_match": 1}}]}}}', - }, - }, -]; export const HostAlertsQueryTabBody = React.memo((alertsProps: AlertsComponentQueryProps) => { const { pageFilters, ...rest } = alertsProps; const hostPageFilters = useMemo( - () => (pageFilters != null ? [...filterHostData, ...pageFilters] : filterHostData), + () => + pageFilters != null + ? [...filterHostExternalAlertData, ...pageFilters] + : filterHostExternalAlertData, [pageFilters] ); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx index b8dcc1dba28a07..879f0fce02fd5d 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx @@ -20,6 +20,8 @@ import { MatrixHistogram } from '../../../common/components/matrix_histogram'; import { HostsKpiChartColors } from '../../components/kpi_hosts/types'; import * as i18n from '../translations'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; +import { authenticationLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/hosts/authentication'; +import { LensAttributes } from '../../../common/components/visualization_actions/types'; const AuthenticationTableManage = manageQuery(AuthenticationTable); @@ -60,6 +62,7 @@ const histogramConfigs: MatrixHistogramConfigs = { mapping: authenticationsMatrixDataMappingFields, stackByOptions: authenticationsStackByOptions, title: i18n.NAVIGATION_AUTHENTICATIONS_TITLE, + lensAttributes: authenticationLensAttributes as LensAttributes, }; const AuthenticationsQueryTabBodyComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index a4889183cdf3d9..59c3322fb02ed7 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -28,6 +28,7 @@ import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; import { defaultCellActions } from '../../../common/lib/cell_actions/default_cell_actions'; +import { getEventsHistogramLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/hosts/events'; const EVENTS_HISTOGRAM_ID = 'eventsHistogramQuery'; @@ -57,6 +58,7 @@ export const histogramConfigs: MatrixHistogramConfigs = { stackByOptions: eventsStackByOptions, subtitle: undefined, title: i18n.NAVIGATION_EVENTS_TITLE, + getLensAttributes: getEventsHistogramLensAttributes, }; const EventsQueryTabBodyComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx index 051fdbdb6513e2..8fbc75aff4e19c 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx @@ -19,6 +19,8 @@ import { StatItems, } from '../../../../common/components/stat_items'; import { UpdateDateRange } from '../../../../common/components/charts/common'; +import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; +import { APP_ID } from '../../../../../common/constants'; const kpiWidgetHeight = 228; @@ -38,6 +40,11 @@ export const NetworkKpiBaseComponent = React.memo<{ narrowDateRange: UpdateDateRange; }>( ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { + const { cases } = useKibana().services; + const CasesContext = cases.ui.getCasesContext(); + const userPermissions = useGetUserCasesPermissions(); + const userCanCrud = userPermissions?.crud ?? false; + const statItemsProps: StatItemsProps[] = useKpiMatrixStatus( fieldsMapping, data, @@ -59,9 +66,11 @@ export const NetworkKpiBaseComponent = React.memo<{ return ( - {statItemsProps.map((mappedStatItemProps) => ( - - ))} + + {statItemsProps.map((mappedStatItemProps) => ( + + ))} + ); }, diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx index c55cfbf7ac87ff..2c9db1cde6daf0 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { StatItems } from '../../../../common/components/stat_items'; +import { kpiDnsQueriesLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_dns_queries'; import { useNetworkKpiDns } from '../../../containers/kpi_network/dns'; import { NetworkKpiBaseComponentManage } from '../common'; import { NetworkKpiProps } from '../types'; @@ -20,6 +21,7 @@ export const fieldsMapping: Readonly = [ { key: 'dnsQueries', value: null, + lensAttributes: kpiDnsQueriesLensAttributes, }, ], description: i18n.DNS_QUERIES, diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts b/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts index 699d91b149ae7c..6f35c4dead2507 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts @@ -7,6 +7,10 @@ import { NetworkKpiStrategyResponse } from '../../../../common/search_strategy'; import { StatItems } from '../../../common/components/stat_items'; +import { kpiUniquePrivateIpsAreaLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_area'; +import { kpiUniquePrivateIpsBarLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_bar'; +import { kpiUniquePrivateIpsDestinationMetricLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_destination_metric'; +import { kpiUniquePrivateIpsSourceMetricLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_source_metric'; export const mockNarrowDateRange = jest.fn(); @@ -107,6 +111,7 @@ export const mockEnableChartsInitialData = { description: 'source', color: '#D36086', icon: 'visMapCoordinate', + lensAttributes: kpiUniquePrivateIpsSourceMetricLensAttributes, }, { key: 'uniqueDestinationPrivateIps', @@ -115,11 +120,14 @@ export const mockEnableChartsInitialData = { description: 'destination', color: '#9170B8', icon: 'visMapCoordinate', + lensAttributes: kpiUniquePrivateIpsDestinationMetricLensAttributes, }, ], description: 'Unique private IPs', enableAreaChart: true, enableBarChart: true, + areaChartLensAttributes: kpiUniquePrivateIpsAreaLensAttributes, + barChartLensAttributes: kpiUniquePrivateIpsBarLensAttributes, areaChart: [], barChart: [ { @@ -205,6 +213,7 @@ export const mockEnableChartsData = { description: 'source', color: '#D36086', icon: 'visMapCoordinate', + lensAttributes: kpiUniquePrivateIpsSourceMetricLensAttributes, }, { key: 'uniqueDestinationPrivateIps', @@ -213,6 +222,7 @@ export const mockEnableChartsData = { description: 'destination', color: '#9170B8', icon: 'visMapCoordinate', + lensAttributes: kpiUniquePrivateIpsDestinationMetricLensAttributes, }, ], from: '2019-06-15T06:00:00.000Z', @@ -220,4 +230,6 @@ export const mockEnableChartsData = { statKey: 'UniqueIps', to: '2019-06-18T06:00:00.000Z', narrowDateRange: mockNarrowDateRange, + areaChartLensAttributes: kpiUniquePrivateIpsAreaLensAttributes, + barChartLensAttributes: kpiUniquePrivateIpsBarLensAttributes, }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx index 6eeb48b5cc07b7..b3630026109263 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx @@ -13,6 +13,7 @@ import { useNetworkKpiNetworkEvents } from '../../../containers/kpi_network/netw import { NetworkKpiBaseComponentManage } from '../common'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { kpiNetworkEventsLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_network_events'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis1 = euiVisColorPalette[1]; @@ -25,6 +26,7 @@ export const fieldsMapping: Readonly = [ key: 'networkEvents', value: null, color: euiColorVis1, + lensAttributes: kpiNetworkEventsLensAttributes, }, ], description: i18n.NETWORK_EVENTS, diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx index e2142a347e0f6a..f05fdb44eba0c7 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { StatItems } from '../../../../common/components/stat_items'; +import { kpiTlsHandshakesLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes'; import { useNetworkKpiTlsHandshakes } from '../../../containers/kpi_network/tls_handshakes'; import { NetworkKpiBaseComponentManage } from '../common'; import { NetworkKpiProps } from '../types'; @@ -20,6 +21,7 @@ export const fieldsMapping: Readonly = [ { key: 'tlsHandshakes', value: null, + lensAttributes: kpiTlsHandshakesLensAttributes, }, ], description: i18n.TLS_HANDSHAKES, diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx index b55787caf56ccb..6f5adc5cd40ca7 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { StatItems } from '../../../../common/components/stat_items'; +import { kpiUniqueFlowIdsLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids'; import { useNetworkKpiUniqueFlows } from '../../../containers/kpi_network/unique_flows'; import { NetworkKpiBaseComponentManage } from '../common'; import { NetworkKpiProps } from '../types'; @@ -20,6 +21,7 @@ export const fieldsMapping: Readonly = [ { key: 'uniqueFlowId', value: null, + lensAttributes: kpiUniqueFlowIdsLensAttributes, }, ], description: i18n.UNIQUE_FLOW_IDS, diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx index d50112d3ca57a8..c3671abe6d2ee6 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx @@ -13,6 +13,10 @@ import { useNetworkKpiUniquePrivateIps } from '../../../containers/kpi_network/u import { NetworkKpiBaseComponentManage } from '../common'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { kpiUniquePrivateIpsSourceMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_source_metric'; +import { kpiUniquePrivateIpsDestinationMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_destination_metric'; +import { kpiUniquePrivateIpsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_area'; +import { kpiUniquePrivateIpsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_bar'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis2 = euiVisColorPalette[2]; @@ -29,6 +33,7 @@ export const fieldsMapping: Readonly = [ description: i18n.SOURCE_UNIT_LABEL, color: euiColorVis2, icon: 'visMapCoordinate', + lensAttributes: kpiUniquePrivateIpsSourceMetricLensAttributes, }, { key: 'uniqueDestinationPrivateIps', @@ -37,11 +42,14 @@ export const fieldsMapping: Readonly = [ description: i18n.DESTINATION_UNIT_LABEL, color: euiColorVis3, icon: 'visMapCoordinate', + lensAttributes: kpiUniquePrivateIpsDestinationMetricLensAttributes, }, ], description: i18n.UNIQUE_PRIVATE_IPS, enableAreaChart: true, enableBarChart: true, + areaChartLensAttributes: kpiUniquePrivateIpsAreaLensAttributes, + barChartLensAttributes: kpiUniquePrivateIpsBarLensAttributes, }, ]; diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/alerts_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/alerts_query_tab_body.tsx index 026aa9f68871f2..ad07147bfc4ed3 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/alerts_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/alerts_query_tab_body.tsx @@ -7,68 +7,17 @@ import React from 'react'; -import type { Filter } from '@kbn/es-query'; import { TimelineId } from '../../../../common/types/timeline'; import { AlertsView } from '../../../common/components/alerts_viewer'; import { NetworkComponentQueryProps } from './types'; - -export const filterNetworkData: Filter[] = [ - { - query: { - bool: { - filter: [ - { - bool: { - should: [ - { - bool: { - should: [ - { - exists: { - field: 'source.ip', - }, - }, - ], - minimum_should_match: 1, - }, - }, - { - bool: { - should: [ - { - exists: { - field: 'destination.ip', - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - }, - }, - meta: { - alias: '', - disabled: false, - key: 'bool', - negate: false, - type: 'custom', - value: - '{"bool":{"filter":[{"bool":{"should":[{"bool":{"should":[{"exists":{"field": "source.ip"}}],"minimum_should_match":1}},{"bool":{"should":[{"exists":{"field": "destination.ip"}}],"minimum_should_match":1}}],"minimum_should_match":1}}]}}', - }, - }, -]; +import { filterNetworkExternalAlertData } from '../../../common/components/visualization_actions/utils'; export const NetworkAlertsQueryTabBody = React.memo((alertsProps: NetworkComponentQueryProps) => ( )); diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx index 7c9b2af5159006..21404690438a02 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx @@ -23,6 +23,7 @@ import { MatrixHistogram } from '../../../common/components/matrix_histogram'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { networkSelectors } from '../../store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { dnsTopDomainsLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/network/dns_top_domains'; const HISTOGRAM_ID = 'networkDnsHistogramQuery'; @@ -44,6 +45,7 @@ export const histogramConfigs: Omit = { histogramType: MatrixHistogramType.dns, stackByOptions: dnsStackByOptions, subtitle: undefined, + lensAttributes: dnsTopDomainsLensAttributes, }; const DnsQueryTabBodyComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx index e7f0d415b194b2..1407bf960843e6 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx @@ -24,6 +24,7 @@ import { inputsActions } from '../../common/store/inputs'; import { Network } from './network'; import { NetworkRoutes } from './navigation'; +import { mockCasesContract } from '../../../../cases/public/mocks'; jest.mock('../../common/containers/sourcerer'); @@ -35,6 +36,9 @@ jest.mock('../../common/components/search_bar', () => ({ jest.mock('../../common/components/query_bar', () => ({ QueryBar: () => null, })); +jest.mock('../../common/components/visualization_actions', () => ({ + VisualizationActions: jest.fn(() =>
), +})); type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; @@ -90,6 +94,9 @@ jest.mock('../../common/lib/kibana', () => { storage: { get: () => true, }, + cases: { + ...mockCasesContract(), + }, }, }), useToasts: jest.fn().mockReturnValue({ diff --git a/x-pack/plugins/security_solution/public/network/pages/network.tsx b/x-pack/plugins/security_solution/public/network/pages/network.tsx index cee068975b19b3..422d2877a85047 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.tsx @@ -36,7 +36,6 @@ import { SpyRoute } from '../../common/utils/route/spy_routes'; import { Display } from '../../hosts/pages/display'; import { networkModel } from '../store'; import { navTabsNetwork, NetworkRoutes, NetworkRoutesLoading } from './navigation'; -import { filterNetworkData } from './navigation/alerts_query_tab_body'; import { OverviewEmpty } from '../../overview/components/overview_empty'; import * as i18n from './translations'; import { NetworkComponentProps } from './types'; @@ -52,6 +51,7 @@ import { timelineDefaults } from '../../timelines/store/timeline/defaults'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../common/hooks/use_selector'; import { useInvalidFilterQuery } from '../../common/hooks/use_invalid_filter_query'; +import { filterNetworkExternalAlertData } from '../../common/components/visualization_actions/utils'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. */ @@ -89,7 +89,9 @@ const NetworkComponent = React.memo( const tabsFilters = useMemo(() => { if (tabName === NetworkRouteType.alerts) { - return filters.length > 0 ? [...filters, ...filterNetworkData] : filterNetworkData; + return filters.length > 0 + ? [...filters, ...filterNetworkExternalAlertData] + : filterNetworkExternalAlertData; } return filters; }, [tabName, filters]); diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx index 0f70c6de362eb9..3402a7bef49052 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx @@ -15,13 +15,46 @@ import { waitFor } from '@testing-library/react'; import { mockIndexPattern, TestProviders } from '../../../common/mock'; import { AlertsByCategory } from '.'; +import { mockCasesContext } from '../../../../../cases/public/mocks/mock_cases_context'; +import { useRouteSpy } from '../../../common/utils/route/use_route_spy'; jest.mock('../../../common/components/link_to'); -jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/components/visualization_actions', () => ({ + VisualizationActions: jest.fn(() =>
), +})); + jest.mock('../../../common/containers/matrix_histogram', () => ({ useMatrixHistogramCombined: jest.fn(), })); +jest.mock('../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + cases: { + ui: { + getCasesContext: jest.fn().mockReturnValue(mockCasesContext), + }, + }, + }, + }), + }; +}); + +jest.mock('../../../common/utils/route/use_route_spy', () => ({ + useRouteSpy: jest.fn().mockReturnValue([ + { + detailName: 'mockHost', + pageName: 'hosts', + tabName: 'externalAlerts', + }, + ]), +})); + const from = '2020-03-31T06:00:00.000Z'; const to = '2019-03-31T06:00:00.000Z'; @@ -139,5 +172,181 @@ describe('Alerts by category', () => { expect(wrapper.find(`.echChart`).exists()).toBe(true); }); }); + + test('it shows visualization actions on host page', async () => { + await waitFor(() => { + expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(true); + }); + }); + + test('it shows visualization actions on network page', async () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: undefined, + pageName: 'network', + tabName: 'external-alerts', + }, + ]); + + const testWrapper = mount( + + + + ); + + await waitFor(() => { + testWrapper.update(); + }); + await waitFor(() => { + expect(testWrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(true); + }); + }); + + test('it does not shows visualization actions on other pages', async () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: undefined, + pageName: 'overview', + tabName: undefined, + }, + ]); + const testWrapper = mount( + + + + ); + + await waitFor(() => { + testWrapper.update(); + }); + + await waitFor(() => { + expect(testWrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(false); + }); + }); + }); + + describe('Host page', () => { + beforeAll(async () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: 'mockHost', + pageName: 'hosts', + tabName: 'externalAlerts', + }, + ]); + + (useMatrixHistogramCombined as jest.Mock).mockReturnValue([ + false, + { + data: [ + { x: 1, y: 2, g: 'g1' }, + { x: 2, y: 4, g: 'g1' }, + { x: 3, y: 6, g: 'g1' }, + { x: 1, y: 1, g: 'g2' }, + { x: 2, y: 3, g: 'g2' }, + { x: 3, y: 5, g: 'g2' }, + ], + inspect: false, + totalCount: 6, + }, + ]); + + wrapper = mount( + + + + ); + + wrapper.update(); + }); + + test('it shows visualization actions', async () => { + await waitFor(() => { + expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(true); + }); + }); + }); + + describe('Network page', () => { + beforeAll(async () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: undefined, + pageName: 'network', + tabName: 'external-alerts', + }, + ]); + (useMatrixHistogramCombined as jest.Mock).mockReturnValue([ + false, + { + data: [ + { x: 1, y: 2, g: 'g1' }, + { x: 2, y: 4, g: 'g1' }, + { x: 3, y: 6, g: 'g1' }, + { x: 1, y: 1, g: 'g2' }, + { x: 2, y: 3, g: 'g2' }, + { x: 3, y: 5, g: 'g2' }, + ], + inspect: false, + totalCount: 6, + }, + ]); + + wrapper = mount( + + + + ); + + wrapper.update(); + }); + + test('it shows visualization actions', async () => { + await waitFor(() => { + expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(true); + }); + }); + }); + + describe('Othen than Host or Network page', () => { + beforeAll(async () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: undefined, + pageName: 'overview', + tabName: undefined, + }, + ]); + (useMatrixHistogramCombined as jest.Mock).mockReturnValue([ + false, + { + data: [ + { x: 1, y: 2, g: 'g1' }, + { x: 2, y: 4, g: 'g1' }, + { x: 3, y: 6, g: 'g1' }, + { x: 1, y: 1, g: 'g2' }, + { x: 2, y: 3, g: 'g2' }, + { x: 3, y: 5, g: 'g2' }, + ], + inspect: false, + totalCount: 6, + }, + ]); + + wrapper = mount( + + + + ); + + wrapper.update(); + }); + + test('it does not shows visualization actions', async () => { + await waitFor(() => { + expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(false); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx index acf85dcf55d181..da01092ed1409a 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx @@ -8,8 +8,10 @@ import numeral from '@elastic/numeral'; import React, { useEffect, useMemo, useCallback } from 'react'; import { Position } from '@elastic/charts'; +import styled from 'styled-components'; import type { DataViewBase, Filter, Query } from '@kbn/es-query'; +import { EuiButton } from '@elastic/eui'; import { DEFAULT_NUMBER_FORMAT, APP_UI_ID } from '../../../../common/constants'; import { SHOWING, UNIT } from '../../../common/components/alerts_viewer/translations'; import { MatrixHistogram } from '../../../common/components/matrix_histogram'; @@ -28,13 +30,15 @@ import { getTabsOnHostsUrl } from '../../../common/components/link_to/redirect_t import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SecurityPageName } from '../../../app/types'; import { useFormatUrl } from '../../../common/components/link_to'; -import { LinkButton } from '../../../common/components/links'; import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; const ID = 'alertsByCategoryOverview'; const DEFAULT_STACK_BY = 'event.module'; +const StyledLinkButton = styled(EuiButton)` + margin-left: ${({ theme }) => theme.eui.paddingSizes.l}; +`; interface Props extends Pick { filters: Filter[]; hideHeaderChildren?: boolean; @@ -74,13 +78,13 @@ const AlertsByCategoryComponent: React.FC = ({ const alertsCountViewAlertsButton = useMemo( () => ( - {i18n.VIEW_ALERTS} - + ), [goToHostAlerts, formatUrl] ); diff --git a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx index b8e04e40e6dfe7..efc5a61d227c06 100644 --- a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx @@ -13,13 +13,15 @@ import type { DataViewBase, Filter, Query } from '@kbn/es-query'; import { ID as OverviewHostQueryId } from '../../containers/overview_host'; import { OverviewHost } from '../overview_host'; import { OverviewNetwork } from '../overview_network'; -import { filterHostData } from '../../../hosts/pages/navigation/alerts_query_tab_body'; import { useKibana } from '../../../common/lib/kibana'; import { convertToBuildEsQuery } from '../../../common/lib/keury'; -import { filterNetworkData } from '../../../network/pages/navigation/alerts_query_tab_body'; import { getEsQueryConfig } from '../../../../../../../src/plugins/data/common'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; +import { + filterHostExternalAlertData, + filterNetworkExternalAlertData, +} from '../../../common/components/visualization_actions/utils'; const HorizontalSpacer = styled(EuiFlexItem)` width: 24px; @@ -49,7 +51,7 @@ const EventCountsComponent: React.FC = ({ config: getEsQueryConfig(uiSettings), indexPattern, queries: [query], - filters: [...filters, ...filterHostData], + filters: [...filters, ...filterHostExternalAlertData], }), [filters, indexPattern, query, uiSettings] ); @@ -60,7 +62,7 @@ const EventCountsComponent: React.FC = ({ config: getEsQueryConfig(uiSettings), indexPattern, queries: [query], - filters: [...filters, ...filterNetworkData], + filters: [...filters, ...filterNetworkExternalAlertData], }), [filters, indexPattern, uiSettings, query] ); diff --git a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx index fc62a3cb8060b7..55903a8b47665d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx @@ -12,6 +12,8 @@ import React, { useEffect, useMemo, useCallback } from 'react'; import uuid from 'uuid'; import type { DataViewBase, Filter, Query } from '@kbn/es-query'; +import styled from 'styled-components'; +import { EuiButton } from '@elastic/eui'; import { DEFAULT_NUMBER_FORMAT, APP_UI_ID } from '../../../../common/constants'; import { SHOWING, UNIT } from '../../../common/components/events_viewer/translations'; import { getTabsOnHostsUrl } from '../../../common/components/link_to/redirect_to_hosts'; @@ -32,7 +34,6 @@ import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import * as i18n from '../../pages/translations'; import { SecurityPageName } from '../../../app/types'; import { useFormatUrl } from '../../../common/components/link_to'; -import { LinkButton } from '../../../common/components/links'; import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; const DEFAULT_STACK_BY = 'event.dataset'; @@ -61,6 +62,10 @@ const getHistogramOption = (fieldName: string): MatrixHistogramOption => ({ value: fieldName, }); +const StyledLinkButton = styled(EuiButton)` + margin-left: ${({ theme }) => theme.eui.paddingSizes.l}; +`; + const EventsByDatasetComponent: React.FC = ({ combinedQueries, deleteQuery, @@ -110,12 +115,12 @@ const EventsByDatasetComponent: React.FC = ({ const eventsCountViewEventsButton = useMemo( () => ( - {i18n.VIEW_EVENTS} - + ), [goToHostEvents, formatUrl] ); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index 200d42075180ce..da36e19d20a55c 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -68,6 +68,10 @@ jest.mock('../../common/containers/local_storage/use_messages_storage'); jest.mock('../containers/overview_cti_links'); +jest.mock('../../common/components/visualization_actions', () => ({ + VisualizationActions: jest.fn(() =>
), +})); + const useCtiDashboardLinksMock = useCtiDashboardLinks as jest.Mock; useCtiDashboardLinksMock.mockReturnValue(mockCtiLinksResponse); diff --git a/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx b/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx index bd4ce3cbae1e3c..41cb19e48e94d4 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx @@ -14,6 +14,7 @@ import { TestProviders } from '../../common/mock'; import { SecuritySolutionTabNavigation } from '../../common/components/navigation'; import { Users } from './users'; import { useSourcererDataView } from '../../common/containers/sourcerer'; +import { mockCasesContext } from '../../../../cases/public/mocks/mock_cases_context'; jest.mock('../../common/containers/sourcerer'); jest.mock('../../common/components/search_bar', () => ({ @@ -22,7 +23,26 @@ jest.mock('../../common/components/search_bar', () => ({ jest.mock('../../common/components/query_bar', () => ({ QueryBar: () => null, })); +jest.mock('../../common/components/visualization_actions', () => ({ + VisualizationActions: jest.fn(() =>
), +})); +jest.mock('../../common/lib/kibana', () => { + const original = jest.requireActual('../../common/lib/kibana'); + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + cases: { + ui: { + getCasesContext: jest.fn().mockReturnValue(mockCasesContext), + }, + }, + }, + }), + }; +}); type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; const location = { From 21ff56b0f8e1fa6e7528c849762d254a54cf521a Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 15 Mar 2022 16:11:52 -0600 Subject: [PATCH 07/39] Fixes throttle bug (#127819) ## Summary See https://github.com/elastic/kibana/issues/127088, this fixes it by adding the throttle with the spaceId Before when you create a rule: You see it stuck in "running at" Screen Shot 2022-03-15 at 1 38 12 PM And an error when you go to alerts -> rules and connectors: Screen Shot 2022-03-15 at 1 38 52 PM Now you see it complete running: Screen Shot 2022-03-15 at 1 51 15 PM And you see it without an error. Screen Shot 2022-03-15 at 1 51 32 PM ### Checklist Did not take the time to figure out a clean way to create e2e tests for this. We need them for throttle but right now there is not an easy way and this is fixing a high priority bug. - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../create_security_rule_type_wrapper.ts | 67 ++++++++++--------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index 25b5471a3c2eb0..0c3c3ec7af4727 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -330,7 +330,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = id: alertId, kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined) ?.kibana_siem_app_url, - outputIndex: ruleDataClient.indexName, + outputIndex: ruleDataClient.indexNameWithNamespace(spaceId), ruleId, esClient: services.scopedClusterClient.asCurrentUser, notificationRuleParams, @@ -352,7 +352,9 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = logger.debug(buildRuleMessage('[+] Signal Rule execution completed.')); logger.debug( buildRuleMessage( - `[+] Finished indexing ${createdSignalsCount} signals into ${ruleDataClient.indexName}` + `[+] Finished indexing ${createdSignalsCount} signals into ${ruleDataClient.indexNameWithNamespace( + spaceId + )}` ) ); @@ -377,6 +379,19 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = ) ); } else { + const errorMessage = buildRuleMessage( + 'Bulk Indexing of signals failed:', + truncateList(result.errors).join() + ); + logger.error(errorMessage); + await ruleExecutionLogger.logStatusChange({ + newStatus: RuleExecutionStatus.failed, + message: errorMessage, + metrics: { + searchDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + }, + }); // NOTE: Since this is throttled we have to call it even on an error condition, otherwise it will "reset" the throttle and fire early if (completeRule.ruleConfig.throttle != null) { await scheduleThrottledNotificationActions({ @@ -386,7 +401,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = id: completeRule.alertId, kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined) ?.kibana_siem_app_url, - outputIndex: ruleDataClient.indexName, + outputIndex: ruleDataClient.indexNameWithNamespace(spaceId), ruleId, esClient: services.scopedClusterClient.asCurrentUser, notificationRuleParams, @@ -394,21 +409,23 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = logger, }); } - const errorMessage = buildRuleMessage( - 'Bulk Indexing of signals failed:', - truncateList(result.errors).join() - ); - logger.error(errorMessage); - await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus.failed, - message: errorMessage, - metrics: { - searchDurations: result.searchAfterTimes, - indexingDurations: result.bulkCreateTimes, - }, - }); } } catch (error) { + const errorMessage = error.message ?? '(no error message given)'; + const message = buildRuleMessage( + 'An error occurred during rule execution:', + `message: "${errorMessage}"` + ); + + logger.error(message); + await ruleExecutionLogger.logStatusChange({ + newStatus: RuleExecutionStatus.failed, + message, + metrics: { + searchDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + }, + }); // NOTE: Since this is throttled we have to call it even on an error condition, otherwise it will "reset" the throttle and fire early if (completeRule.ruleConfig.throttle != null) { await scheduleThrottledNotificationActions({ @@ -418,7 +435,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = id: completeRule.alertId, kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined) ?.kibana_siem_app_url, - outputIndex: ruleDataClient.indexName, + outputIndex: ruleDataClient.indexNameWithNamespace(spaceId), ruleId, esClient: services.scopedClusterClient.asCurrentUser, notificationRuleParams, @@ -426,22 +443,6 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = logger, }); } - - const errorMessage = error.message ?? '(no error message given)'; - const message = buildRuleMessage( - 'An error occurred during rule execution:', - `message: "${errorMessage}"` - ); - - logger.error(message); - await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus.failed, - message, - metrics: { - searchDurations: result.searchAfterTimes, - indexingDurations: result.bulkCreateTimes, - }, - }); } return result.state; From c9058b850c8adf43d6f6c123b6644b87956e2d14 Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Tue, 15 Mar 2022 18:16:50 -0400 Subject: [PATCH 08/39] Add troubleshooting setup guide link to enterprise search instructions page (#127820) --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + .../shared/doc_links/doc_links.ts | 3 ++ .../shared/setup_guide/cloud/instructions.tsx | 31 +++++++++++++++++++ .../shared/setup_guide/instructions.tsx | 27 ++++++++++++++++ 5 files changed, 63 insertions(+) diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index cbdfbab4f1e91b..aaa15eb1eb312e 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -109,6 +109,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { configuration: `${ENTERPRISE_SEARCH_DOCS}configuration.html`, licenseManagement: `${ENTERPRISE_SEARCH_DOCS}license-management.html`, mailService: `${ENTERPRISE_SEARCH_DOCS}mailer-configuration.html`, + troubleshootSetup: `${ENTERPRISE_SEARCH_DOCS}troubleshoot-setup.html`, usersAccess: `${ENTERPRISE_SEARCH_DOCS}users-access.html`, }, workplaceSearch: { diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 9cfa4d90005842..30a7d0fd783942 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -99,6 +99,7 @@ export interface DocLinks { readonly configuration: string; readonly licenseManagement: string; readonly mailService: string; + readonly troubleshootSetup: string; readonly usersAccess: string; }; readonly workplaceSearch: { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts index 841bf8e35731de..b87f0434547cfa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -32,6 +32,7 @@ class DocLinks { public cloudIndexManagement: string; public enterpriseSearchConfig: string; public enterpriseSearchMailService: string; + public enterpriseSearchTroubleshootSetup: string; public enterpriseSearchUsersAccess: string; public kibanaSecurity: string; public licenseManagement: string; @@ -86,6 +87,7 @@ class DocLinks { this.cloudIndexManagement = ''; this.enterpriseSearchConfig = ''; this.enterpriseSearchMailService = ''; + this.enterpriseSearchTroubleshootSetup = ''; this.enterpriseSearchUsersAccess = ''; this.kibanaSecurity = ''; this.licenseManagement = ''; @@ -141,6 +143,7 @@ class DocLinks { this.cloudIndexManagement = docLinks.links.cloud.indexManagement; this.enterpriseSearchConfig = docLinks.links.enterpriseSearch.configuration; this.enterpriseSearchMailService = docLinks.links.enterpriseSearch.mailService; + this.enterpriseSearchTroubleshootSetup = docLinks.links.enterpriseSearch.troubleshootSetup; this.enterpriseSearchUsersAccess = docLinks.links.enterpriseSearch.usersAccess; this.kibanaSecurity = docLinks.links.kibana.xpackSecurity; this.licenseManagement = docLinks.links.enterpriseSearch.licenseManagement; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx index 8d41e221a2cc76..4fc5b061671972 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx @@ -135,6 +135,37 @@ export const CloudSetupInstructions: React.FC = ({ productName, cloudDepl ), }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.cloud.step6.title', { + defaultMessage: 'Troubleshooting issues', + }), + children: ( + <> + +

+ + {i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step6.instruction1LinkText', + { defaultMessage: 'Troubleshoot Enterprise Search setup' } + )} + + ), + }} + /> +

+
+ + ), + }, ]} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/instructions.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/instructions.tsx index 4fc12f62305fa4..948299c772480f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/instructions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/instructions.tsx @@ -14,10 +14,14 @@ import { EuiCode, EuiCodeBlock, EuiAccordion, + EuiLink, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { docLinks } from '../doc_links'; + interface Props { productName: string; } @@ -94,6 +98,29 @@ export const SetupInstructions: React.FC = ({ productName }) => (

+ + +

+ + {i18n.translate( + 'xpack.enterpriseSearch.troubleshooting.setup.documentationLinkLabel', + { defaultMessage: 'Troubleshoot Enterprise Search setup' } + )} + + ), + }} + /> +

+
), }, From dd3af76aa99597382eab4d3e7a1111f65e40e4e5 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Tue, 15 Mar 2022 23:21:00 +0100 Subject: [PATCH 09/39] [Shared UX] Migrate PageTemplate > NoDataPage >ElasticAgent Card (#127505) * [Shared UX] Migrate PageTemplate > NoDataPage > NoDataCard >ElasticAgentCard to Shared UX * Add more unit tests * Add more unit tests * Fix typescript & unit test * Fix snapshot * Add optional property to no_data_card * update test * Updating snapshot * Integrate RedirectAppLinks * Updating failing snapshots * Add TODO * Removed `renderFooter` prop in favor of hiding if `isDisabled` * Added `max-width` style to NoDataCard * Nit: Change name of illustration from `logo` to `illustration` * Undo generic `.svg` type change Co-authored-by: cchaos --- .../exit_full_screen_button.test.tsx.snap | 14 + .../public/components/page_template/index.tsx | 2 +- ...elastic_agent_card.component.test.tsx.snap | 142 ++++++++ .../elastic_agent_card.test.tsx.snap | 320 ++++++++++++++++++ .../__snapshots__/no_data_card.test.tsx.snap | 142 +++++++- .../assets/elastic_agent_card.svg | 1 + .../elastic_agent_card.component.test.tsx | 82 +++++ .../elastic_agent_card.component.tsx | 82 +++++ .../elastic_agent_card.stories.tsx | 39 +++ .../no_data_card/elastic_agent_card.test.tsx | 64 ++++ .../no_data_card/elastic_agent_card.tsx | 41 +++ .../no_data_page/no_data_card/index.ts | 1 + .../no_data_card/no_data_card.stories.tsx | 6 +- .../no_data_card/no_data_card.styles.ts | 15 + .../no_data_card/no_data_card.test.tsx | 24 ++ .../no_data_card/no_data_card.tsx | 16 +- .../no_data_page/no_data_card/types.ts | 15 +- .../redirect_app_links/redirect_app_links.tsx | 1 - .../__snapshots__/primary.test.tsx.snap | 14 + .../shared_ux/public/services/application.ts | 14 + src/plugins/shared_ux/public/services/http.ts | 11 + .../shared_ux/public/services/index.tsx | 8 + .../public/services/kibana/application.ts | 24 ++ .../shared_ux/public/services/kibana/http.ts | 23 ++ .../shared_ux/public/services/kibana/index.ts | 4 + .../public/services/kibana/permissions.ts | 6 +- .../public/services/mocks/application.mock.ts | 21 ++ .../public/services/mocks/http.mock.ts | 19 ++ .../shared_ux/public/services/mocks/index.ts | 4 + .../public/services/mocks/permissions.mock.ts | 1 + .../shared_ux/public/services/permissions.ts | 1 + .../public/services/storybook/application.ts | 25 ++ .../public/services/storybook/http.ts | 24 ++ .../public/services/storybook/index.ts | 4 + .../public/services/storybook/permissions.ts | 1 + .../public/services/stub/application.ts | 27 ++ .../shared_ux/public/services/stub/http.ts | 24 ++ .../shared_ux/public/services/stub/index.ts | 4 + .../public/services/stub/permissions.ts | 1 + 39 files changed, 1250 insertions(+), 17 deletions(-) create mode 100644 src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.component.test.tsx.snap create mode 100644 src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap create mode 100644 src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/assets/elastic_agent_card.svg create mode 100644 src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx create mode 100644 src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx create mode 100644 src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx create mode 100644 src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.test.tsx create mode 100644 src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.tsx create mode 100644 src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.styles.ts create mode 100644 src/plugins/shared_ux/public/services/application.ts create mode 100644 src/plugins/shared_ux/public/services/http.ts create mode 100644 src/plugins/shared_ux/public/services/kibana/application.ts create mode 100644 src/plugins/shared_ux/public/services/kibana/http.ts create mode 100644 src/plugins/shared_ux/public/services/mocks/application.mock.ts create mode 100644 src/plugins/shared_ux/public/services/mocks/http.mock.ts create mode 100644 src/plugins/shared_ux/public/services/storybook/application.ts create mode 100644 src/plugins/shared_ux/public/services/storybook/http.ts create mode 100644 src/plugins/shared_ux/public/services/stub/application.ts create mode 100644 src/plugins/shared_ux/public/services/stub/http.ts diff --git a/src/plugins/shared_ux/public/components/exit_full_screen_button/__snapshots__/exit_full_screen_button.test.tsx.snap b/src/plugins/shared_ux/public/components/exit_full_screen_button/__snapshots__/exit_full_screen_button.test.tsx.snap index d2609e6b3c7a64..36759a656a0350 100644 --- a/src/plugins/shared_ux/public/components/exit_full_screen_button/__snapshots__/exit_full_screen_button.test.tsx.snap +++ b/src/plugins/shared_ux/public/components/exit_full_screen_button/__snapshots__/exit_full_screen_button.test.tsx.snap @@ -2,6 +2,14 @@ exports[` is rendered 1`] = ` is rendered 1`] = ` "openDataViewEditor": [MockFunction], } } + http={ + Object { + "addBasePath": [MockFunction], + } + } permissions={ Object { + "canAccessFleet": true, "canCreateNewDataView": true, } } diff --git a/src/plugins/shared_ux/public/components/page_template/index.tsx b/src/plugins/shared_ux/public/components/page_template/index.tsx index 1f686183c2edb3..0c4fb2e066bfc4 100644 --- a/src/plugins/shared_ux/public/components/page_template/index.tsx +++ b/src/plugins/shared_ux/public/components/page_template/index.tsx @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { NoDataCard } from './no_data_page/no_data_card'; +export { NoDataCard, ElasticAgentCard } from './no_data_page/no_data_card'; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.component.test.tsx.snap b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.component.test.tsx.snap new file mode 100644 index 00000000000000..127b4f2d9f4b4a --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.component.test.tsx.snap @@ -0,0 +1,142 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ElasticAgentCardComponent props button 1`] = ` + + + +`; + +exports[`ElasticAgentCardComponent props href 1`] = ` + + + +`; + +exports[`ElasticAgentCardComponent props recommended 1`] = ` + + + +`; + +exports[`ElasticAgentCardComponent renders 1`] = ` + + + +`; + +exports[`ElasticAgentCardComponent renders with canAccessFleet false 1`] = ` + + + This integration is not yet enabled. Your administrator has the required permissions to turn it on. + + } + image="test-file-stub" + isDisabled={true} + title={ + + Contact your administrator + + } + /> + +`; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap new file mode 100644 index 00000000000000..dd8ae6993ecc65 --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap @@ -0,0 +1,320 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ElasticAgentCard renders 1`] = ` +.emotion-0 { + max-width: 400px; +} + + + + + +
+ + + Add Elastic Agent + + } + href="/app/integrations/browse" + image="test-file-stub" + paddingSize="l" + title="Add Elastic Agent" + > + + +
+
+
+ +
+
+
+ + + + Add Elastic Agent + + + + +
+

+ Use Elastic Agent for a simple, unified way to collect data from your machines. +

+
+
+
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap index fccbbe3a9e8eee..31b4df17a27793 100644 --- a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/__snapshots__/no_data_card.test.tsx.snap @@ -1,8 +1,59 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`NoDataCard props button 1`] = ` +.emotion-0 { + max-width: 400px; +} +
+
+ + Card title + +
+

+ Description +

+
+
+ +
+`; + +exports[`NoDataCard props extends EuiCardProps 1`] = ` +.emotion-0 { + max-width: 400px; +} + +
`; +exports[`NoDataCard props isDisabled 1`] = ` +.emotion-0 { + max-width: 400px; +} + +
+
+ + + +
+

+ Description +

+
+
+
+`; + exports[`NoDataCard props recommended 1`] = ` +.emotion-0 { + max-width: 400px; +} +
+
`; exports[`NoDataCard renders 1`] = ` +.emotion-0 { + max-width: 400px; +} +
+
`; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/assets/elastic_agent_card.svg b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/assets/elastic_agent_card.svg new file mode 100644 index 00000000000000..82d2bf8541864f --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/assets/elastic_agent_card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx new file mode 100644 index 00000000000000..bba01e3c71c389 --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { ElasticAgentCardComponent } from './elastic_agent_card.component'; +import { NoDataCard } from './no_data_card'; +import { Subject } from 'rxjs'; + +describe('ElasticAgentCardComponent', () => { + const navigateToUrl = jest.fn(); + const currentAppId$ = new Subject().asObservable(); + + test('renders', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + test('renders with canAccessFleet false', () => { + const component = shallow( + + ); + expect(component.find(NoDataCard).props().isDisabled).toBe(true); + expect(component).toMatchSnapshot(); + }); + + describe('props', () => { + test('recommended', () => { + const component = shallow( + + ); + expect(component.find(NoDataCard).props().recommended).toBe(true); + expect(component).toMatchSnapshot(); + }); + + test('button', () => { + const component = shallow( + + ); + expect(component.find(NoDataCard).props().button).toBe('Button'); + expect(component).toMatchSnapshot(); + }); + + test('href', () => { + const component = shallow( + + ); + expect(component.find(NoDataCard).props().href).toBe('some path'); + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx new file mode 100644 index 00000000000000..81b7b6bc61a2d1 --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiTextColor } from '@elastic/eui'; +import { Observable } from 'rxjs'; +import { ElasticAgentCardProps } from './types'; +import { NoDataCard } from './no_data_card'; +import ElasticAgentCardIllustration from './assets/elastic_agent_card.svg'; +import { RedirectAppLinks } from '../../../redirect_app_links'; + +export type ElasticAgentCardComponentProps = ElasticAgentCardProps & { + canAccessFleet: boolean; + navigateToUrl: (url: string) => Promise; + currentAppId$: Observable; +}; + +const noPermissionTitle = i18n.translate( + 'sharedUX.noDataPage.elasticAgentCard.noPermission.title', + { + defaultMessage: `Contact your administrator`, + } +); + +const noPermissionDescription = i18n.translate( + 'sharedUX.noDataPage.elasticAgentCard.noPermission.description', + { + defaultMessage: `This integration is not yet enabled. Your administrator has the required permissions to turn it on.`, + } +); + +const elasticAgentCardTitle = i18n.translate('sharedUX.noDataPage.elasticAgentCard.title', { + defaultMessage: 'Add Elastic Agent', +}); + +const elasticAgentCardDescription = i18n.translate( + 'sharedUX.noDataPage.elasticAgentCard.description', + { + defaultMessage: `Use Elastic Agent for a simple, unified way to collect data from your machines.`, + } +); + +/** + * Creates a specific NoDataCard pointing users to Integrations when `canAccessFleet` + */ +export const ElasticAgentCardComponent: FunctionComponent = ({ + canAccessFleet, + title, + navigateToUrl, + currentAppId$, + ...cardRest +}) => { + const noAccessCard = ( + {noPermissionTitle}} + description={{noPermissionDescription}} + isDisabled + {...cardRest} + /> + ); + const card = ( + + ); + + return ( + + {canAccessFleet ? card : noAccessCard} + + ); +}; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx new file mode 100644 index 00000000000000..ceeb66cbd8cdd7 --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { + ElasticAgentCardComponent, + ElasticAgentCardComponentProps, +} from './elastic_agent_card.component'; +import { applicationServiceFactory } from '../../../../services/storybook/application'; + +export default { + title: 'Elastic Agent Data Card', + description: 'A solution-specific wrapper around NoDataCard, to be used on NoData page', +}; + +type Params = Pick; + +export const PureComponent = (params: Params) => { + const { currentAppId$, navigateToUrl } = applicationServiceFactory(); + return ( + + ); +}; + +PureComponent.argTypes = { + canAccessFleet: { + control: 'boolean', + defaultValue: true, + }, +}; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.test.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.test.tsx new file mode 100644 index 00000000000000..fc9e72a011241f --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ReactWrapper } from 'enzyme'; +import React from 'react'; +import { ElasticAgentCard } from './elastic_agent_card'; + +import { servicesFactory } from '../../../../services/mocks'; +import { ServicesProvider, SharedUXServices } from '../../../../services'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { ElasticAgentCardComponent } from './elastic_agent_card.component'; + +describe('ElasticAgentCard', () => { + let services: SharedUXServices; + let mount: (element: JSX.Element) => ReactWrapper; + + beforeEach(() => { + services = servicesFactory(); + mount = (element: JSX.Element) => + mountWithIntl({element}); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('renders', () => { + const component = mount(); + expect(component).toMatchSnapshot(); + }); + + describe('href', () => { + test('returns href if href is given', () => { + const component = mount(); + expect(component.find(ElasticAgentCardComponent).props().href).toBe('/take/me/somewhere'); + }); + + test('returns prefix + category if href is not given', () => { + const component = mount(); + expect(component.find(ElasticAgentCardComponent).props().href).toBe( + '/app/integrations/browse/solutions' + ); + }); + + test('returns prefix if nor category nor href are given', () => { + const component = mount(); + expect(component.find(ElasticAgentCardComponent).props().href).toBe( + '/app/integrations/browse' + ); + }); + }); + + describe('canAccessFleet', () => { + test('passes in the right parameter', () => { + const component = mount(); + expect(component.find(ElasticAgentCardComponent).props().canAccessFleet).toBe(true); + }); + }); +}); diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.tsx new file mode 100644 index 00000000000000..f99c4ec9196f55 --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/elastic_agent_card.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { ElasticAgentCardProps } from './types'; +import { useApplication, useHttp, usePermissions } from '../../../../services'; +import { ElasticAgentCardComponent } from './elastic_agent_card.component'; + +export const ElasticAgentCard = (props: ElasticAgentCardProps) => { + const { canAccessFleet } = usePermissions(); + const { addBasePath } = useHttp(); + const { navigateToUrl, currentAppId$ } = useApplication(); + + const createHref = () => { + const { href, category } = props; + if (href) { + return href; + } + // TODO: get this URL from a locator + const prefix = '/app/integrations/browse'; + if (category) { + return addBasePath(`${prefix}/${category}`); + } + return prefix; + }; + + return ( + + ); +}; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/index.ts b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/index.ts index 5f226aba691a87..328580f6692874 100644 --- a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/index.ts +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/index.ts @@ -6,3 +6,4 @@ * Side Public License, v 1. */ export { NoDataCard } from './no_data_card'; +export { ElasticAgentCard } from './elastic_agent_card'; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.stories.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.stories.tsx index 6f496d50d72729..087ff7a48e01fc 100644 --- a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.stories.tsx +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.stories.tsx @@ -18,11 +18,7 @@ export default { type Params = Pick; export const PureComponent = (params: Params) => { - return ( -
- -
- ); + return ; }; PureComponent.argTypes = { diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.styles.ts b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.styles.ts new file mode 100644 index 00000000000000..6eff5a39fe58b0 --- /dev/null +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.styles.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const NO_DATA_CARD_MAX_WIDTH = 400; + +export const NoDataCardStyles = () => { + return { + maxWidth: NO_DATA_CARD_MAX_WIDTH, + }; +}; diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.test.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.test.tsx index a809ede2dc6171..c53743e74b9fa8 100644 --- a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.test.tsx +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.test.tsx @@ -37,5 +37,29 @@ describe('NoDataCard', () => { ); expect(component).toMatchSnapshot(); }); + + test('isDisabled', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + + test('extends EuiCardProps', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); }); }); diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.tsx b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.tsx index 9f545985065dcc..2ea601757fbf7d 100644 --- a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.tsx +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/no_data_card.tsx @@ -10,13 +10,14 @@ import { i18n } from '@kbn/i18n'; import React, { FunctionComponent } from 'react'; import { EuiButton, EuiCard } from '@elastic/eui'; import type { NoDataCardProps } from './types'; +import { NoDataCardStyles } from './no_data_card.styles'; const recommendedLabel = i18n.translate('sharedUX.pageTemplate.noDataPage.recommendedLabel', { defaultMessage: 'Recommended', }); const defaultDescription = i18n.translate('sharedUX.pageTemplate.noDataCard.description', { - defaultMessage: `Proceed without collecting data`, + defaultMessage: 'Proceed without collecting data', }); export const NoDataCard: FunctionComponent = ({ @@ -24,12 +25,21 @@ export const NoDataCard: FunctionComponent = ({ title, button, description, + isDisabled, ...cardRest }) => { + const styles = NoDataCardStyles(); + const footer = () => { - if (typeof button !== 'string') { + // Don't render the footer action if disabled + if (isDisabled) { + return; + } + // Render a custom footer action if the button is not a simple string + if (button && typeof button !== 'string') { return button; } + // Default footer action is a button with the provided or default string return {button || title}; }; const label = recommended ? recommendedLabel : undefined; @@ -37,11 +47,13 @@ export const NoDataCard: FunctionComponent = ({ return ( ); diff --git a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/types.ts b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/types.ts index e08d9fdeaaa337..a2dcc1a6294555 100644 --- a/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/types.ts +++ b/src/plugins/shared_ux/public/components/page_template/no_data_page/no_data_card/types.ts @@ -15,7 +15,8 @@ export type NoDataCardProps = Partial> & { */ recommended?: boolean; /** - * Provide just a string for the button's label, or a whole component + * Provide just a string for the button's label, or a whole component; + * The button will be hidden completely if `isDisabled=true` */ button?: string | ReactNode; /** @@ -23,7 +24,15 @@ export type NoDataCardProps = Partial> & { */ onClick?: MouseEventHandler; /** - * Description for the card. If not provided, the default will be used. + * Description for the card; + * If not provided, the default will be used */ - description?: string; + description?: string | ReactNode; +}; + +export type ElasticAgentCardProps = NoDataCardProps & { + /** + * Category to auto-select within Fleet + */ + category?: string; }; diff --git a/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx index 6354914684fb6f..5c2d9260639ca8 100644 --- a/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx +++ b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx @@ -44,7 +44,6 @@ export const RedirectAppLinks: FunctionComponent = ({ }) => { const currentAppId = useObservable(currentAppId$, undefined); const containerRef = useRef(null); - const clickHandler = useMemo( () => containerRef.current && currentAppId diff --git a/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/__snapshots__/primary.test.tsx.snap b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/__snapshots__/primary.test.tsx.snap index 1d7e3acb0b7628..8148afa76d790e 100644 --- a/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/__snapshots__/primary.test.tsx.snap +++ b/src/plugins/shared_ux/public/components/toolbar/solution_toolbar/button/__snapshots__/primary.test.tsx.snap @@ -2,6 +2,14 @@ exports[` is rendered 1`] = ` is rendered 1`] = ` "openDataViewEditor": [MockFunction], } } + http={ + Object { + "addBasePath": [MockFunction], + } + } permissions={ Object { + "canAccessFleet": true, "canCreateNewDataView": true, } } diff --git a/src/plugins/shared_ux/public/services/application.ts b/src/plugins/shared_ux/public/services/application.ts new file mode 100644 index 00000000000000..b1901bd79c6d2a --- /dev/null +++ b/src/plugins/shared_ux/public/services/application.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Observable } from 'rxjs'; + +export interface SharedUXApplicationService { + navigateToUrl: (url: string) => Promise; + currentAppId$: Observable; +} diff --git a/src/plugins/shared_ux/public/services/http.ts b/src/plugins/shared_ux/public/services/http.ts new file mode 100644 index 00000000000000..da32d659b445fd --- /dev/null +++ b/src/plugins/shared_ux/public/services/http.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface SharedUXHttpService { + addBasePath: (url: string) => string; +} diff --git a/src/plugins/shared_ux/public/services/index.tsx b/src/plugins/shared_ux/public/services/index.tsx index bdca90c725858c..e83e1eab4b95de 100644 --- a/src/plugins/shared_ux/public/services/index.tsx +++ b/src/plugins/shared_ux/public/services/index.tsx @@ -12,6 +12,8 @@ import { servicesFactory } from './stub'; import { SharedUXUserPermissionsService } from './permissions'; import { SharedUXEditorsService } from './editors'; import { SharedUXDocLinksService } from './doc_links'; +import { SharedUXHttpService } from './http'; +import { SharedUXApplicationService } from './application'; /** * A collection of services utilized by SharedUX. This serves as a thin @@ -26,6 +28,8 @@ export interface SharedUXServices { permissions: SharedUXUserPermissionsService; editors: SharedUXEditorsService; docLinks: SharedUXDocLinksService; + http: SharedUXHttpService; + application: SharedUXApplicationService; } // The React Context used to provide the services to the SharedUX components. @@ -60,3 +64,7 @@ export const usePermissions = () => useServices().permissions; export const useEditors = () => useServices().editors; export const useDocLinks = () => useServices().docLinks; + +export const useHttp = () => useServices().http; + +export const useApplication = () => useServices().application; diff --git a/src/plugins/shared_ux/public/services/kibana/application.ts b/src/plugins/shared_ux/public/services/kibana/application.ts new file mode 100644 index 00000000000000..dfb13e60882702 --- /dev/null +++ b/src/plugins/shared_ux/public/services/kibana/application.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPluginServiceFactory } from '../types'; +import { SharedUXPluginStartDeps } from '../../types'; +import { SharedUXApplicationService } from '../application'; + +export type ApplicationServiceFactory = KibanaPluginServiceFactory< + SharedUXApplicationService, + SharedUXPluginStartDeps +>; + +/** + * A factory function for creating a Kibana-based implementation of `SharedUXEditorsService`. + */ +export const applicationServiceFactory: ApplicationServiceFactory = ({ coreStart }) => ({ + navigateToUrl: coreStart.application.navigateToUrl, + currentAppId$: coreStart.application.currentAppId$, +}); diff --git a/src/plugins/shared_ux/public/services/kibana/http.ts b/src/plugins/shared_ux/public/services/kibana/http.ts new file mode 100644 index 00000000000000..0947d416724df3 --- /dev/null +++ b/src/plugins/shared_ux/public/services/kibana/http.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPluginServiceFactory } from '../types'; +import { SharedUXHttpService } from '../http'; +import { SharedUXPluginStartDeps } from '../../types'; + +export type HttpServiceFactory = KibanaPluginServiceFactory< + SharedUXHttpService, + SharedUXPluginStartDeps +>; + +/** + * A factory function for creating a Kibana-based implementation of `SharedUXEditorsService`. + */ +export const httpServiceFactory: HttpServiceFactory = ({ coreStart, startPlugins }) => ({ + addBasePath: coreStart.http.basePath.prepend, +}); diff --git a/src/plugins/shared_ux/public/services/kibana/index.ts b/src/plugins/shared_ux/public/services/kibana/index.ts index 506176cffbc458..0df8604379b4f8 100644 --- a/src/plugins/shared_ux/public/services/kibana/index.ts +++ b/src/plugins/shared_ux/public/services/kibana/index.ts @@ -13,6 +13,8 @@ import { platformServiceFactory } from './platform'; import { userPermissionsServiceFactory } from './permissions'; import { editorsServiceFactory } from './editors'; import { docLinksServiceFactory } from './doc_links'; +import { httpServiceFactory } from './http'; +import { applicationServiceFactory } from './application'; /** * A factory function for creating a Kibana-based implementation of `SharedUXServices`. @@ -25,4 +27,6 @@ export const servicesFactory: KibanaPluginServiceFactory< permissions: userPermissionsServiceFactory(params), editors: editorsServiceFactory(params), docLinks: docLinksServiceFactory(params), + http: httpServiceFactory(params), + application: applicationServiceFactory(params), }); diff --git a/src/plugins/shared_ux/public/services/kibana/permissions.ts b/src/plugins/shared_ux/public/services/kibana/permissions.ts index caaeccdccbccd5..5b1cedd51e969e 100644 --- a/src/plugins/shared_ux/public/services/kibana/permissions.ts +++ b/src/plugins/shared_ux/public/services/kibana/permissions.ts @@ -18,6 +18,10 @@ export type UserPermissionsServiceFactory = KibanaPluginServiceFactory< /** * A factory function for creating a Kibana-based implementation of `SharedUXPermissionsService`. */ -export const userPermissionsServiceFactory: UserPermissionsServiceFactory = ({ startPlugins }) => ({ +export const userPermissionsServiceFactory: UserPermissionsServiceFactory = ({ + coreStart, + startPlugins, +}) => ({ canCreateNewDataView: startPlugins.dataViewEditor.userPermissions.editDataView(), + canAccessFleet: coreStart.application.capabilities.navLinks.integrations, }); diff --git a/src/plugins/shared_ux/public/services/mocks/application.mock.ts b/src/plugins/shared_ux/public/services/mocks/application.mock.ts new file mode 100644 index 00000000000000..c2010956401e82 --- /dev/null +++ b/src/plugins/shared_ux/public/services/mocks/application.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginServiceFactory } from '../types'; +import { SharedUXApplicationService } from '../application'; +import { Observable } from 'rxjs'; + +export type MockApplicationServiceFactory = PluginServiceFactory; + +/** + * A factory function for creating a Jest-based implementation of `SharedUXApplicationService`. + */ +export const applicationServiceFactory: MockApplicationServiceFactory = () => ({ + navigateToUrl: () => Promise.resolve(), + currentAppId$: new Observable(), +}); diff --git a/src/plugins/shared_ux/public/services/mocks/http.mock.ts b/src/plugins/shared_ux/public/services/mocks/http.mock.ts new file mode 100644 index 00000000000000..1751f2b77efc1d --- /dev/null +++ b/src/plugins/shared_ux/public/services/mocks/http.mock.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginServiceFactory } from '../types'; +import { SharedUXHttpService } from '../http'; + +export type MockHttpServiceFactory = PluginServiceFactory; + +/** + * A factory function for creating a Jest-based implementation of `SharedUXHttpService`. + */ +export const httpServiceFactory: MockHttpServiceFactory = () => ({ + addBasePath: jest.fn((path: string) => (path ? path : 'path')), +}); diff --git a/src/plugins/shared_ux/public/services/mocks/index.ts b/src/plugins/shared_ux/public/services/mocks/index.ts index 9fce633c52539c..8fb4372295d760 100644 --- a/src/plugins/shared_ux/public/services/mocks/index.ts +++ b/src/plugins/shared_ux/public/services/mocks/index.ts @@ -15,6 +15,8 @@ import { PluginServiceFactory } from '../types'; import { platformServiceFactory } from './platform.mock'; import { userPermissionsServiceFactory } from './permissions.mock'; import { editorsServiceFactory } from './editors.mock'; +import { httpServiceFactory } from './http.mock'; +import { applicationServiceFactory } from './application.mock'; /** * A factory function for creating a Jest-based implementation of `SharedUXServices`. @@ -24,4 +26,6 @@ export const servicesFactory: PluginServiceFactory = () => ({ permissions: userPermissionsServiceFactory(), editors: editorsServiceFactory(), docLinks: docLinksServiceFactory(), + http: httpServiceFactory(), + application: applicationServiceFactory(), }); diff --git a/src/plugins/shared_ux/public/services/mocks/permissions.mock.ts b/src/plugins/shared_ux/public/services/mocks/permissions.mock.ts index ff65d2393248ab..4884d5071ec43e 100644 --- a/src/plugins/shared_ux/public/services/mocks/permissions.mock.ts +++ b/src/plugins/shared_ux/public/services/mocks/permissions.mock.ts @@ -17,4 +17,5 @@ export type MockUserPermissionsServiceFactory = */ export const userPermissionsServiceFactory: MockUserPermissionsServiceFactory = () => ({ canCreateNewDataView: true, + canAccessFleet: true, }); diff --git a/src/plugins/shared_ux/public/services/permissions.ts b/src/plugins/shared_ux/public/services/permissions.ts index 009f497e357063..8e8f5a54341d6b 100644 --- a/src/plugins/shared_ux/public/services/permissions.ts +++ b/src/plugins/shared_ux/public/services/permissions.ts @@ -8,4 +8,5 @@ export interface SharedUXUserPermissionsService { canCreateNewDataView: boolean; + canAccessFleet: boolean; } diff --git a/src/plugins/shared_ux/public/services/storybook/application.ts b/src/plugins/shared_ux/public/services/storybook/application.ts new file mode 100644 index 00000000000000..9b38d3b28689ea --- /dev/null +++ b/src/plugins/shared_ux/public/services/storybook/application.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; +import { action } from '@storybook/addon-actions'; +import { PluginServiceFactory } from '../types'; +import { SharedUXApplicationService } from '../application'; + +export type ApplicationServiceFactory = PluginServiceFactory; + +/** + * A factory function for creating for creating a storybook implementation of `SharedUXApplicationService`. + */ +export const applicationServiceFactory: ApplicationServiceFactory = () => ({ + navigateToUrl: () => { + action('NavigateToUrl'); + return Promise.resolve(); + }, + currentAppId$: new BehaviorSubject('123'), +}); diff --git a/src/plugins/shared_ux/public/services/storybook/http.ts b/src/plugins/shared_ux/public/services/storybook/http.ts new file mode 100644 index 00000000000000..d5a04d45879675 --- /dev/null +++ b/src/plugins/shared_ux/public/services/storybook/http.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { action } from '@storybook/addon-actions'; + +import { PluginServiceFactory } from '../types'; +import { SharedUXHttpService } from '../http'; + +/** + * A factory function for creating a Storybook-based implementation of `SharedUXHttpService`. + */ +export type HttpServiceFactory = PluginServiceFactory; + +/** + * A factory function for creating a Storybook-based implementation of `SharedUXHttpService`. + */ +export const httpServiceFactory: HttpServiceFactory = () => ({ + addBasePath: action('addBasePath') as SharedUXHttpService['addBasePath'], +}); diff --git a/src/plugins/shared_ux/public/services/storybook/index.ts b/src/plugins/shared_ux/public/services/storybook/index.ts index c915b1a4633652..878df9ca2beba6 100644 --- a/src/plugins/shared_ux/public/services/storybook/index.ts +++ b/src/plugins/shared_ux/public/services/storybook/index.ts @@ -12,6 +12,8 @@ import { platformServiceFactory } from './platform'; import { editorsServiceFactory } from './editors'; import { userPermissionsServiceFactory } from './permissions'; import { docLinksServiceFactory } from './doc_links'; +import { httpServiceFactory } from './http'; +import { applicationServiceFactory } from './application'; /** * A factory function for creating a Storybook-based implementation of `SharedUXServices`. @@ -21,4 +23,6 @@ export const servicesFactory: PluginServiceFactory = (para permissions: userPermissionsServiceFactory(), editors: editorsServiceFactory(), docLinks: docLinksServiceFactory(), + http: httpServiceFactory(params), + application: applicationServiceFactory(), }); diff --git a/src/plugins/shared_ux/public/services/storybook/permissions.ts b/src/plugins/shared_ux/public/services/storybook/permissions.ts index c7110fccf7eeab..299e06f31f1237 100644 --- a/src/plugins/shared_ux/public/services/storybook/permissions.ts +++ b/src/plugins/shared_ux/public/services/storybook/permissions.ts @@ -17,4 +17,5 @@ export type SharedUXUserPermissionsServiceFactory = */ export const userPermissionsServiceFactory: SharedUXUserPermissionsServiceFactory = () => ({ canCreateNewDataView: true, + canAccessFleet: true, }); diff --git a/src/plugins/shared_ux/public/services/stub/application.ts b/src/plugins/shared_ux/public/services/stub/application.ts new file mode 100644 index 00000000000000..25ad680a48073b --- /dev/null +++ b/src/plugins/shared_ux/public/services/stub/application.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Observable } from 'rxjs'; +import { PluginServiceFactory } from '../types'; +import { SharedUXApplicationService } from '../application'; + +export type ApplicationServiceFactory = PluginServiceFactory; + +/** + * A factory function for creating for creating a simple stubbed implementation of `SharedUXApplicationService`. + */ +export const applicationServiceFactory: ApplicationServiceFactory = () => ({ + navigateToUrl: (url) => { + // eslint-disable-next-line no-console + console.log(url); + return Promise.resolve(); + }, + currentAppId$: new Observable((subscriber) => { + subscriber.next('123'); + }), +}); diff --git a/src/plugins/shared_ux/public/services/stub/http.ts b/src/plugins/shared_ux/public/services/stub/http.ts new file mode 100644 index 00000000000000..4fb101d4e85f6f --- /dev/null +++ b/src/plugins/shared_ux/public/services/stub/http.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginServiceFactory } from '../types'; +import { SharedUXHttpService } from '../http'; + +/** + * A factory function for creating a simple stubbed implementation of `SharedUXHttpService`. + */ +export type HttpServiceFactory = PluginServiceFactory; + +/** + * A factory function for creating a simple stubbed implementation of `SharedUXHttpService`. + */ +export const httpServiceFactory: HttpServiceFactory = () => ({ + addBasePath: (url: string) => { + return url; + }, +}); diff --git a/src/plugins/shared_ux/public/services/stub/index.ts b/src/plugins/shared_ux/public/services/stub/index.ts index 9e4fa8f03133aa..0a8a0b7bd3dafc 100644 --- a/src/plugins/shared_ux/public/services/stub/index.ts +++ b/src/plugins/shared_ux/public/services/stub/index.ts @@ -12,6 +12,8 @@ import { platformServiceFactory } from './platform'; import { userPermissionsServiceFactory } from './permissions'; import { editorsServiceFactory } from './editors'; import { docLinksServiceFactory } from './doc_links'; +import { httpServiceFactory } from './http'; +import { applicationServiceFactory } from './application'; /** * A factory function for creating a simple stubbed implemetation of `SharedUXServices`. @@ -21,4 +23,6 @@ export const servicesFactory: PluginServiceFactory = () => ({ permissions: userPermissionsServiceFactory(), editors: editorsServiceFactory(), docLinks: docLinksServiceFactory(), + http: httpServiceFactory(), + application: applicationServiceFactory(), }); diff --git a/src/plugins/shared_ux/public/services/stub/permissions.ts b/src/plugins/shared_ux/public/services/stub/permissions.ts index c51abf41e28429..2b9e9f4a8f4093 100644 --- a/src/plugins/shared_ux/public/services/stub/permissions.ts +++ b/src/plugins/shared_ux/public/services/stub/permissions.ts @@ -19,4 +19,5 @@ export type UserPermissionsServiceFactory = PluginServiceFactory ({ canCreateNewDataView: true, + canAccessFleet: true, }); From 1832fa723d58e5e485204006b20a66359b3d801d Mon Sep 17 00:00:00 2001 From: Mat Schaffer Date: Wed, 16 Mar 2022 07:29:59 +0900 Subject: [PATCH 10/39] [Stack Monitoring] A basic architectural overview (#127577) --- .../reference/architectural_overview.md | 31 +++++++++++++++++++ x-pack/plugins/monitoring/readme.md | 4 +-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/monitoring/dev_docs/reference/architectural_overview.md b/x-pack/plugins/monitoring/dev_docs/reference/architectural_overview.md index e69de29bb2d1d6..1966aac82cc2d5 100644 --- a/x-pack/plugins/monitoring/dev_docs/reference/architectural_overview.md +++ b/x-pack/plugins/monitoring/dev_docs/reference/architectural_overview.md @@ -0,0 +1,31 @@ +At a very high level, Stack Monitoring is intended to gather and display metrics and logging data for Elastic Stack Components. + +A single [monitoring deployment](../reference/terminology.md#monitoring-deployment) can be used to monitor multiple Elastic Stack Components across a range of previous stack versions. + +```mermaid +graph LR + +D712[7.12 deployment] +D717[7.17 deployment] +D80[8.0 deployment] + +subgraph monitoring + Emonitoring[Elasticsearch]-->Kmonitoring[Kibana - Stack Monitoring UI] +end + +D712-.->|publish|Emonitoring +D717-.->|publish|Emonitoring +D80-.->|publish|Emonitoring +``` + +Each monitored deployment can use a different [data collection mode](../reference/data_collection_modes.md) or potentially even multiple different modes within a given deployment. + +Additionally, the monitoring deployment can contain [rules](../reference/rules_alerts.md) the alert operators of exceptional conditions in the production (monitored) deployments. + +The root stack monitoring page will display a list of clusters if data is found for more than one cluster. + +Once a cluster is selected, the monitoring deployment will display a set of cards for each stack component in the cluster. + +Clicking on one of the overview cards will navigate to a metrics display for the corresponding stack component. + +The logs and metrics displayed here should provide guidance for an operator to resolve any issues that may be present in the monitored deployment. \ No newline at end of file diff --git a/x-pack/plugins/monitoring/readme.md b/x-pack/plugins/monitoring/readme.md index 1aee5f55023c8d..73aa8dccd2f741 100644 --- a/x-pack/plugins/monitoring/readme.md +++ b/x-pack/plugins/monitoring/readme.md @@ -8,7 +8,7 @@ This plugin provides the Stack Monitoring kibana application. - [Testing](dev_docs/how_to/testing.md) ## Concepts -- [Architectural Overview](dev_docs/reference/architectural_overview.md) (WIP) +- [Architectural Overview](dev_docs/reference/architectural_overview.md) - [Terminology](dev_docs/reference/terminology.md) (WIP) - [Data Collection modes](dev_docs/reference/data_collection_modes.md) (WIP) - [Rules and Alerts](dev_docs/reference/rules_alerts.md) @@ -19,4 +19,4 @@ This plugin provides the Stack Monitoring kibana application. ## Troubleshooting - [Diagnostic queries](dev_docs/runbook/diagnostic_queries.md) -- [CPU metrics](dev_docs/runbook/cpu_metrics.md) +- [CPU metrics](dev_docs/runbook/cpu_metrics.md) \ No newline at end of file From 1133c3f6eb75979bc7d4ef768f52f0d2606d3b79 Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 15 Mar 2022 16:33:56 -0600 Subject: [PATCH 11/39] [optimizer] harden cache to bazel output changing mtime (#127609) --- .../kbn-optimizer/src/common/array_helpers.ts | 11 -- .../kbn-optimizer/src/common/bundle.test.ts | 28 ++--- packages/kbn-optimizer/src/common/bundle.ts | 9 +- .../src/common/bundle_cache.test.ts | 8 +- .../kbn-optimizer/src/common/bundle_cache.ts | 6 +- packages/kbn-optimizer/src/common/hashes.ts | 66 ++++++++++++ packages/kbn-optimizer/src/common/index.ts | 1 + .../basic_optimization.test.ts | 6 +- .../integration_tests/bundle_cache.test.ts | 65 ++++++----- .../optimizer_built_paths.test.ts | 72 +++++++++++++ .../watch_bundles_for_changes.test.ts | 4 +- .../src/optimizer/bundle_cache.ts | 23 ++-- .../src/optimizer/cache_keys.test.ts | 102 ------------------ .../kbn-optimizer/src/optimizer/cache_keys.ts | 95 ---------------- .../src/optimizer/diff_cache_key.ts | 25 +++++ .../src/optimizer/get_changes.test.ts | 51 --------- .../src/optimizer/get_changes.ts | 55 ---------- .../src/optimizer/get_mtimes.test.ts | 35 ------ .../kbn-optimizer/src/optimizer/get_mtimes.ts | 39 ------- packages/kbn-optimizer/src/optimizer/index.ts | 3 +- .../src/optimizer/optimizer_built_paths.ts | 26 +++++ .../src/optimizer/optimizer_cache_key.test.ts | 84 +++++++++++++++ .../src/optimizer/optimizer_cache_key.ts | 29 +++++ .../kbn-optimizer/src/optimizer/watcher.ts | 10 +- .../worker/populate_bundle_cache_plugin.ts | 48 +++++---- 25 files changed, 406 insertions(+), 495 deletions(-) create mode 100644 packages/kbn-optimizer/src/common/hashes.ts create mode 100644 packages/kbn-optimizer/src/integration_tests/optimizer_built_paths.test.ts delete mode 100644 packages/kbn-optimizer/src/optimizer/cache_keys.test.ts delete mode 100644 packages/kbn-optimizer/src/optimizer/cache_keys.ts create mode 100644 packages/kbn-optimizer/src/optimizer/diff_cache_key.ts delete mode 100644 packages/kbn-optimizer/src/optimizer/get_changes.test.ts delete mode 100644 packages/kbn-optimizer/src/optimizer/get_changes.ts delete mode 100644 packages/kbn-optimizer/src/optimizer/get_mtimes.test.ts delete mode 100644 packages/kbn-optimizer/src/optimizer/get_mtimes.ts create mode 100644 packages/kbn-optimizer/src/optimizer/optimizer_built_paths.ts create mode 100644 packages/kbn-optimizer/src/optimizer/optimizer_cache_key.test.ts create mode 100644 packages/kbn-optimizer/src/optimizer/optimizer_cache_key.ts diff --git a/packages/kbn-optimizer/src/common/array_helpers.ts b/packages/kbn-optimizer/src/common/array_helpers.ts index 1dbce46f2e8a71..3639d0280e3e18 100644 --- a/packages/kbn-optimizer/src/common/array_helpers.ts +++ b/packages/kbn-optimizer/src/common/array_helpers.ts @@ -62,14 +62,3 @@ export const descending = (...getters: Array>): Comparator< * Alternate Array#includes() implementation with sane types, functions as a type guard */ export const includes = (array: T[], value: any): value is T => array.includes(value); - -/** - * Ponyfill for Object.fromEntries() - */ -export const entriesToObject = (entries: Array): Record => { - const object: Record = {}; - for (const [key, value] of entries) { - object[key] = value; - } - return object; -}; diff --git a/packages/kbn-optimizer/src/common/bundle.test.ts b/packages/kbn-optimizer/src/common/bundle.test.ts index 9dbaae9f36f200..76488c1caab5e1 100644 --- a/packages/kbn-optimizer/src/common/bundle.test.ts +++ b/packages/kbn-optimizer/src/common/bundle.test.ts @@ -7,6 +7,7 @@ */ import { Bundle, BundleSpec, parseBundles } from './bundle'; +import { Hashes } from './hashes'; jest.mock('fs'); @@ -21,20 +22,21 @@ const SPEC: BundleSpec = { it('creates cache keys', () => { const bundle = new Bundle(SPEC); - expect( - bundle.createCacheKey( - ['/foo/bar/a', '/foo/bar/c'], - new Map([ - ['/foo/bar/a', 123], - ['/foo/bar/b', 456], - ['/foo/bar/c', 789], - ]) - ) - ).toMatchInlineSnapshot(` + + // randomly sort the hash entries to make sure that the cache key never changes based on the order of the hash cache + const hashEntries = [ + ['/foo/bar/a', '123'] as const, + ['/foo/bar/b', '456'] as const, + ['/foo/bar/c', '789'] as const, + ].sort(() => (Math.random() > 0.5 ? 1 : -1)); + + const hashes = new Hashes(new Map(hashEntries)); + + expect(bundle.createCacheKey(['/foo/bar/a', '/foo/bar/c'], hashes)).toMatchInlineSnapshot(` Object { - "mtimes": Object { - "/foo/bar/a": 123, - "/foo/bar/c": 789, + "checksums": Object { + "/foo/bar/a": "123", + "/foo/bar/c": "789", }, "spec": Object { "banner": undefined, diff --git a/packages/kbn-optimizer/src/common/bundle.ts b/packages/kbn-optimizer/src/common/bundle.ts index 7feaa960b79e74..7216fdcacf32a1 100644 --- a/packages/kbn-optimizer/src/common/bundle.ts +++ b/packages/kbn-optimizer/src/common/bundle.ts @@ -12,7 +12,8 @@ import Fs from 'fs'; import { BundleCache } from './bundle_cache'; import { UnknownVals } from './ts_helpers'; import { omit } from './obj_helpers'; -import { includes, ascending, entriesToObject } from './array_helpers'; +import { includes } from './array_helpers'; +import type { Hashes } from './hashes'; const VALID_BUNDLE_TYPES = ['plugin' as const, 'entry' as const]; @@ -89,12 +90,10 @@ export class Bundle { * Calculate the cache key for this bundle based from current * mtime values. */ - createCacheKey(files: string[], mtimes: Map): unknown { + createCacheKey(paths: string[], hashes: Hashes): unknown { return { spec: omit(this.toSpec(), ['pageLoadAssetSizeLimit']), - mtimes: entriesToObject( - files.map((p) => [p, mtimes.get(p)] as const).sort(ascending((e) => e[0])) - ), + checksums: Object.fromEntries(paths.map((p) => [p, hashes.getCached(p)] as const)), }; } diff --git a/packages/kbn-optimizer/src/common/bundle_cache.test.ts b/packages/kbn-optimizer/src/common/bundle_cache.test.ts index e903a687908b9f..f5c7866adef9fa 100644 --- a/packages/kbn-optimizer/src/common/bundle_cache.test.ts +++ b/packages/kbn-optimizer/src/common/bundle_cache.test.ts @@ -15,7 +15,7 @@ const mockWriteFileSync: jest.Mock = jest.requireMock('fs').writeFileSync; const SOME_STATE: State = { cacheKey: 'abc', - files: ['123'], + referencedPaths: ['123'], moduleCount: 123, optimizerCacheKey: 'abc', }; @@ -49,7 +49,7 @@ it(`updates files on disk when calling set()`, () => { "/foo/.kbn-optimizer-cache", "{ \\"cacheKey\\": \\"abc\\", - \\"files\\": [ + \\"referencedPaths\\": [ \\"123\\" ], \\"moduleCount\\": 123, @@ -94,14 +94,14 @@ it('provides accessors to specific state properties', () => { const cache = new BundleCache('/foo'); expect(cache.getModuleCount()).toBe(undefined); - expect(cache.getReferencedFiles()).toEqual(undefined); + expect(cache.getReferencedPaths()).toEqual(undefined); expect(cache.getCacheKey()).toEqual(undefined); expect(cache.getOptimizerCacheKey()).toEqual(undefined); cache.set(SOME_STATE); expect(cache.getModuleCount()).toBe(123); - expect(cache.getReferencedFiles()).toEqual(['123']); + expect(cache.getReferencedPaths()).toEqual(['123']); expect(cache.getCacheKey()).toEqual('abc'); expect(cache.getOptimizerCacheKey()).toEqual('abc'); }); diff --git a/packages/kbn-optimizer/src/common/bundle_cache.ts b/packages/kbn-optimizer/src/common/bundle_cache.ts index 7c0770caa26235..60a33929adc03a 100644 --- a/packages/kbn-optimizer/src/common/bundle_cache.ts +++ b/packages/kbn-optimizer/src/common/bundle_cache.ts @@ -17,7 +17,7 @@ export interface State { cacheKey?: unknown; moduleCount?: number; workUnits?: number; - files?: string[]; + referencedPaths?: string[]; bundleRefExportIds?: string[]; } @@ -82,8 +82,8 @@ export class BundleCache { return this.get().moduleCount; } - public getReferencedFiles() { - return this.get().files; + public getReferencedPaths() { + return this.get().referencedPaths; } public getBundleRefExportIds() { diff --git a/packages/kbn-optimizer/src/common/hashes.ts b/packages/kbn-optimizer/src/common/hashes.ts new file mode 100644 index 00000000000000..a062008a9fd5b0 --- /dev/null +++ b/packages/kbn-optimizer/src/common/hashes.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Fs from 'fs'; +import { pipeline } from 'stream/promises'; +import { createHash } from 'crypto'; +import { asyncMapWithLimit, asyncForEachWithLimit } from '@kbn/std'; + +export class Hashes { + static async hashFile(path: string) { + const hash = createHash('sha256'); + try { + await pipeline(Fs.createReadStream(path), hash); + return hash.digest('hex'); + } catch (error) { + if (error && error.code === 'ENOENT') { + return null; + } + + throw error; + } + } + + static hash(content: Buffer) { + return createHash('sha256').update(content).digest('hex'); + } + + static async ofFiles(paths: string[]) { + return new Hashes( + new Map( + await asyncMapWithLimit(paths, 100, async (path) => { + return [path, await Hashes.hashFile(path)]; + }) + ) + ); + } + + constructor(public readonly cache = new Map()) {} + + async populate(paths: string[]) { + await asyncForEachWithLimit(paths, 100, async (path) => { + if (!this.cache.has(path)) { + this.cache.set(path, await Hashes.hashFile(path)); + } + }); + } + + getCached(path: string) { + const cached = this.cache.get(path); + if (cached === undefined) { + throw new Error(`hash for path [${path}] is not cached`); + } + return cached; + } + + cacheToJson() { + return Object.fromEntries( + Array.from(this.cache.entries()).filter((e): e is [string, string] => e[1] !== null) + ); + } +} diff --git a/packages/kbn-optimizer/src/common/index.ts b/packages/kbn-optimizer/src/common/index.ts index 7914d74fa92990..c3054ad02a84b9 100644 --- a/packages/kbn-optimizer/src/common/index.ts +++ b/packages/kbn-optimizer/src/common/index.ts @@ -19,3 +19,4 @@ export * from './event_stream_helpers'; export * from './parse_path'; export * from './theme_tags'; export * from './obj_helpers'; +export * from './hashes'; diff --git a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts index 4a515a7a9684f5..767069e3a295f6 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -131,7 +131,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { expect(foo).toBeTruthy(); foo.cache.refresh(); expect(foo.cache.getModuleCount()).toBe(6); - expect(foo.cache.getReferencedFiles()).toMatchInlineSnapshot(` + expect(foo.cache.getReferencedPaths()).toMatchInlineSnapshot(` Array [ /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/bazel-out/-fastbuild/bin/packages/kbn-ui-shared-deps-npm/target_node/public_path_module_creator.js, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/kibana.json, @@ -151,7 +151,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { 16 ); - expect(bar.cache.getReferencedFiles()).toMatchInlineSnapshot(` + expect(bar.cache.getReferencedPaths()).toMatchInlineSnapshot(` Array [ /node_modules/@kbn/optimizer/postcss.config.js, /node_modules/css-loader/package.json, @@ -174,7 +174,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { baz.cache.refresh(); expect(baz.cache.getModuleCount()).toBe(3); - expect(baz.cache.getReferencedFiles()).toMatchInlineSnapshot(` + expect(baz.cache.getReferencedPaths()).toMatchInlineSnapshot(` Array [ /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/bazel-out/-fastbuild/bin/packages/kbn-ui-shared-deps-npm/target_node/public_path_module_creator.js, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/kibana.json, diff --git a/packages/kbn-optimizer/src/integration_tests/bundle_cache.test.ts b/packages/kbn-optimizer/src/integration_tests/bundle_cache.test.ts index c2dab041b0bd2f..55f2c08ed0e345 100644 --- a/packages/kbn-optimizer/src/integration_tests/bundle_cache.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/bundle_cache.test.ts @@ -12,9 +12,8 @@ import cpy from 'cpy'; import del from 'del'; import { createAbsolutePathSerializer } from '@kbn/dev-utils'; -import { getMtimes } from '../optimizer/get_mtimes'; import { OptimizerConfig } from '../optimizer/optimizer_config'; -import { allValuesFrom, Bundle } from '../common'; +import { allValuesFrom, Bundle, Hashes } from '../common'; import { getBundleCacheEvent$ } from '../optimizer/bundle_cache'; const TMP_DIR = Path.resolve(__dirname, '../__fixtures__/__tmp__'); @@ -50,19 +49,19 @@ it('emits "bundle cached" event when everything is updated', async () => { const [bundle] = config.bundles; const optimizerCacheKey = 'optimizerCacheKey'; - const files = [ + const referencedPaths = [ Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/ext.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), ]; - const mtimes = await getMtimes(files); - const cacheKey = bundle.createCacheKey(files, mtimes); + const hashes = await Hashes.ofFiles(referencedPaths); + const cacheKey = bundle.createCacheKey(referencedPaths, hashes); bundle.cache.set({ cacheKey, optimizerCacheKey, - files, - moduleCount: files.length, + referencedPaths, + moduleCount: referencedPaths.length, bundleRefExportIds: [], }); @@ -89,19 +88,19 @@ it('emits "bundle not cached" event when cacheKey is up to date but caching is d const [bundle] = config.bundles; const optimizerCacheKey = 'optimizerCacheKey'; - const files = [ + const referencedPaths = [ Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/ext.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), ]; - const mtimes = await getMtimes(files); - const cacheKey = bundle.createCacheKey(files, mtimes); + const hashes = await Hashes.ofFiles(referencedPaths); + const cacheKey = bundle.createCacheKey(referencedPaths, hashes); bundle.cache.set({ cacheKey, optimizerCacheKey, - files, - moduleCount: files.length, + referencedPaths, + moduleCount: referencedPaths.length, bundleRefExportIds: [], }); @@ -128,19 +127,19 @@ it('emits "bundle not cached" event when optimizerCacheKey is missing', async () const [bundle] = config.bundles; const optimizerCacheKey = 'optimizerCacheKey'; - const files = [ + const referencedPaths = [ Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/ext.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), ]; - const mtimes = await getMtimes(files); - const cacheKey = bundle.createCacheKey(files, mtimes); + const hashes = await Hashes.ofFiles(referencedPaths); + const cacheKey = bundle.createCacheKey(referencedPaths, hashes); bundle.cache.set({ cacheKey, optimizerCacheKey: undefined, - files, - moduleCount: files.length, + referencedPaths, + moduleCount: referencedPaths.length, bundleRefExportIds: [], }); @@ -167,19 +166,19 @@ it('emits "bundle not cached" event when optimizerCacheKey is outdated, includes const [bundle] = config.bundles; const optimizerCacheKey = 'optimizerCacheKey'; - const files = [ + const referencedPaths = [ Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/ext.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), ]; - const mtimes = await getMtimes(files); - const cacheKey = bundle.createCacheKey(files, mtimes); + const hashes = await Hashes.ofFiles(referencedPaths); + const cacheKey = bundle.createCacheKey(referencedPaths, hashes); bundle.cache.set({ cacheKey, optimizerCacheKey: 'old', - files, - moduleCount: files.length, + referencedPaths, + moduleCount: referencedPaths.length, bundleRefExportIds: [], }); @@ -211,19 +210,19 @@ it('emits "bundle not cached" event when bundleRefExportIds is outdated, include const [bundle] = config.bundles; const optimizerCacheKey = 'optimizerCacheKey'; - const files = [ + const referencedPaths = [ Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/ext.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), ]; - const mtimes = await getMtimes(files); - const cacheKey = bundle.createCacheKey(files, mtimes); + const hashes = await Hashes.ofFiles(referencedPaths); + const cacheKey = bundle.createCacheKey(referencedPaths, hashes); bundle.cache.set({ cacheKey, optimizerCacheKey, - files, - moduleCount: files.length, + referencedPaths, + moduleCount: referencedPaths.length, bundleRefExportIds: ['plugin/bar/public'], }); @@ -256,7 +255,7 @@ it('emits "bundle not cached" event when cacheKey is missing', async () => { const [bundle] = config.bundles; const optimizerCacheKey = 'optimizerCacheKey'; - const files = [ + const referencedPaths = [ Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/ext.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), @@ -265,8 +264,8 @@ it('emits "bundle not cached" event when cacheKey is missing', async () => { bundle.cache.set({ cacheKey: undefined, optimizerCacheKey, - files, - moduleCount: files.length, + referencedPaths, + moduleCount: referencedPaths.length, bundleRefExportIds: [], }); @@ -293,7 +292,7 @@ it('emits "bundle not cached" event when cacheKey is outdated', async () => { const [bundle] = config.bundles; const optimizerCacheKey = 'optimizerCacheKey'; - const files = [ + const referencedPaths = [ Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/ext.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), @@ -302,8 +301,8 @@ it('emits "bundle not cached" event when cacheKey is outdated', async () => { bundle.cache.set({ cacheKey: 'old', optimizerCacheKey, - files, - moduleCount: files.length, + referencedPaths, + moduleCount: referencedPaths.length, bundleRefExportIds: [], }); diff --git a/packages/kbn-optimizer/src/integration_tests/optimizer_built_paths.test.ts b/packages/kbn-optimizer/src/integration_tests/optimizer_built_paths.test.ts new file mode 100644 index 00000000000000..db3819d78156dc --- /dev/null +++ b/packages/kbn-optimizer/src/integration_tests/optimizer_built_paths.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-expect-error +import { getOptimizerBuiltPaths } from '@kbn/optimizer/target_node/optimizer/optimizer_built_paths'; +import { createAbsolutePathSerializer } from '@kbn/dev-utils'; + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); + +it(`finds all the optimizer files relative to it's path`, async () => { + const paths = await getOptimizerBuiltPaths(); + expect(paths).toMatchInlineSnapshot(` + Array [ + /node_modules/@kbn/optimizer/target_node/cli.js, + /node_modules/@kbn/optimizer/target_node/common/array_helpers.js, + /node_modules/@kbn/optimizer/target_node/common/bundle_cache.js, + /node_modules/@kbn/optimizer/target_node/common/bundle_refs.js, + /node_modules/@kbn/optimizer/target_node/common/bundle.js, + /node_modules/@kbn/optimizer/target_node/common/compiler_messages.js, + /node_modules/@kbn/optimizer/target_node/common/event_stream_helpers.js, + /node_modules/@kbn/optimizer/target_node/common/hashes.js, + /node_modules/@kbn/optimizer/target_node/common/index.js, + /node_modules/@kbn/optimizer/target_node/common/obj_helpers.js, + /node_modules/@kbn/optimizer/target_node/common/parse_path.js, + /node_modules/@kbn/optimizer/target_node/common/rxjs_helpers.js, + /node_modules/@kbn/optimizer/target_node/common/theme_tags.js, + /node_modules/@kbn/optimizer/target_node/common/ts_helpers.js, + /node_modules/@kbn/optimizer/target_node/common/worker_config.js, + /node_modules/@kbn/optimizer/target_node/common/worker_messages.js, + /node_modules/@kbn/optimizer/target_node/index.js, + /node_modules/@kbn/optimizer/target_node/limits.js, + /node_modules/@kbn/optimizer/target_node/log_optimizer_progress.js, + /node_modules/@kbn/optimizer/target_node/log_optimizer_state.js, + /node_modules/@kbn/optimizer/target_node/optimizer/assign_bundles_to_workers.js, + /node_modules/@kbn/optimizer/target_node/optimizer/bundle_cache.js, + /node_modules/@kbn/optimizer/target_node/optimizer/diff_cache_key.js, + /node_modules/@kbn/optimizer/target_node/optimizer/filter_by_id.js, + /node_modules/@kbn/optimizer/target_node/optimizer/focus_bundles.js, + /node_modules/@kbn/optimizer/target_node/optimizer/get_plugin_bundles.js, + /node_modules/@kbn/optimizer/target_node/optimizer/handle_optimizer_completion.js, + /node_modules/@kbn/optimizer/target_node/optimizer/index.js, + /node_modules/@kbn/optimizer/target_node/optimizer/kibana_platform_plugins.js, + /node_modules/@kbn/optimizer/target_node/optimizer/observe_stdio.js, + /node_modules/@kbn/optimizer/target_node/optimizer/observe_worker.js, + /node_modules/@kbn/optimizer/target_node/optimizer/optimizer_built_paths.js, + /node_modules/@kbn/optimizer/target_node/optimizer/optimizer_cache_key.js, + /node_modules/@kbn/optimizer/target_node/optimizer/optimizer_config.js, + /node_modules/@kbn/optimizer/target_node/optimizer/optimizer_state.js, + /node_modules/@kbn/optimizer/target_node/optimizer/run_workers.js, + /node_modules/@kbn/optimizer/target_node/optimizer/watch_bundles_for_changes.js, + /node_modules/@kbn/optimizer/target_node/optimizer/watcher.js, + /node_modules/@kbn/optimizer/target_node/report_optimizer_timings.js, + /node_modules/@kbn/optimizer/target_node/run_optimizer.js, + /node_modules/@kbn/optimizer/target_node/worker/bundle_metrics_plugin.js, + /node_modules/@kbn/optimizer/target_node/worker/bundle_ref_module.js, + /node_modules/@kbn/optimizer/target_node/worker/bundle_refs_plugin.js, + /node_modules/@kbn/optimizer/target_node/worker/emit_stats_plugin.js, + /node_modules/@kbn/optimizer/target_node/worker/entry_point_creator.js, + /node_modules/@kbn/optimizer/target_node/worker/populate_bundle_cache_plugin.js, + /node_modules/@kbn/optimizer/target_node/worker/run_compilers.js, + /node_modules/@kbn/optimizer/target_node/worker/run_worker.js, + /node_modules/@kbn/optimizer/target_node/worker/theme_loader.js, + /node_modules/@kbn/optimizer/target_node/worker/webpack_helpers.js, + /node_modules/@kbn/optimizer/target_node/worker/webpack.config.js, + ] + `); +}); diff --git a/packages/kbn-optimizer/src/integration_tests/watch_bundles_for_changes.test.ts b/packages/kbn-optimizer/src/integration_tests/watch_bundles_for_changes.test.ts index 2c5668766ed5e1..4ba0a19fc3f5bf 100644 --- a/packages/kbn-optimizer/src/integration_tests/watch_bundles_for_changes.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/watch_bundles_for_changes.test.ts @@ -35,7 +35,7 @@ const makeTestBundle = (id: string) => { cacheKey: 'abc', moduleCount: 1, optimizerCacheKey: 'abc', - files: [bundleEntryPath(bundle)], + referencedPaths: [bundleEntryPath(bundle)], }); return bundle; @@ -83,7 +83,7 @@ it('notifies of changes and completes once all bundles have changed', async () = return; } - // first we change foo and bar, and after 1 second get that change comes though + // first we change foo and bar, after 1 second that change comes though if (i === 1) { expect(event.bundles).toHaveLength(2); const [bar, foo] = event.bundles.sort(ascending((b) => b.id)); diff --git a/packages/kbn-optimizer/src/optimizer/bundle_cache.ts b/packages/kbn-optimizer/src/optimizer/bundle_cache.ts index 375e6a9a3d2b1b..78b0cf7a437e56 100644 --- a/packages/kbn-optimizer/src/optimizer/bundle_cache.ts +++ b/packages/kbn-optimizer/src/optimizer/bundle_cache.ts @@ -9,11 +9,10 @@ import * as Rx from 'rxjs'; import { mergeAll } from 'rxjs/operators'; -import { Bundle, BundleRefs } from '../common'; +import { Bundle, BundleRefs, Hashes } from '../common'; import { OptimizerConfig } from './optimizer_config'; -import { getMtimes } from './get_mtimes'; -import { diffCacheKey } from './cache_keys'; +import { diffCacheKey } from './diff_cache_key'; export type BundleCacheEvent = BundleNotCachedEvent | BundleCachedEvent; @@ -114,20 +113,12 @@ export function getBundleCacheEvent$( eligibleBundles.push(bundle); } - const mtimes = await getMtimes( - new Set( - eligibleBundles.reduce( - (acc: string[], bundle) => [...acc, ...(bundle.cache.getReferencedFiles() || [])], - [] - ) - ) - ); - + const hashes = new Hashes(); for (const bundle of eligibleBundles) { - const diff = diffCacheKey( - bundle.cache.getCacheKey(), - bundle.createCacheKey(bundle.cache.getReferencedFiles() || [], mtimes) - ); + const paths = bundle.cache.getReferencedPaths() ?? []; + await hashes.populate(paths); + + const diff = diffCacheKey(bundle.cache.getCacheKey(), bundle.createCacheKey(paths, hashes)); if (diff) { events.push({ diff --git a/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts b/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts deleted file mode 100644 index 7287362849472c..00000000000000 --- a/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import Path from 'path'; - -import { REPO_ROOT } from '@kbn/utils'; -import { createAbsolutePathSerializer } from '@kbn/dev-utils'; - -import { getOptimizerCacheKey } from './cache_keys'; -import { OptimizerConfig } from './optimizer_config'; - -jest.mock('./get_changes.ts', () => ({ - getChanges: async () => - new Map([ - ['/foo/bar/a', 'modified'], - ['/foo/bar/b', 'modified'], - ['/foo/bar/c', 'deleted'], - ]), -})); - -jest.mock('./get_mtimes.ts', () => ({ - getMtimes: async (paths: string[]) => new Map(paths.map((path) => [path, 12345])), -})); - -jest.mock('execa'); - -jest.mock('fs', () => { - const realFs = jest.requireActual('fs'); - return { - ...realFs, - readFile: jest.fn(realFs.readFile), - }; -}); - -expect.addSnapshotSerializer(createAbsolutePathSerializer()); - -jest.requireMock('execa').mockImplementation(async (cmd: string, args: string[], opts: object) => { - expect(cmd).toBe('git'); - expect(args).toEqual([ - 'log', - '-n', - '1', - '--pretty=format:%H', - '--', - expect.stringContaining('kbn-optimizer'), - ]); - expect(opts).toEqual({ - cwd: REPO_ROOT, - }); - - return { - stdout: '', - }; -}); - -describe('getOptimizerCacheKey()', () => { - it('uses latest commit, bootstrap cache, and changed files to create unique value', async () => { - jest - .requireMock('fs') - .readFile.mockImplementation( - (path: string, enc: string, cb: (err: null, file: string) => void) => { - expect(path).toBe( - Path.resolve(REPO_ROOT, 'packages/kbn-optimizer/target/.bootstrap-cache') - ); - expect(enc).toBe('utf8'); - cb(null, ''); - } - ); - - const config = OptimizerConfig.create({ - repoRoot: REPO_ROOT, - }); - - await expect(getOptimizerCacheKey(config)).resolves.toMatchInlineSnapshot(` - Object { - "deletedPaths": Array [ - "/foo/bar/c", - ], - "lastCommit": "", - "modifiedTimes": Object { - "/foo/bar/a": 12345, - "/foo/bar/b": 12345, - }, - "workerConfig": Object { - "browserslistEnv": "dev", - "dist": false, - "optimizerCacheKey": "♻", - "repoRoot": , - "themeTags": Array [ - "v8dark", - "v8light", - ], - }, - } - `); - }); -}); diff --git a/packages/kbn-optimizer/src/optimizer/cache_keys.ts b/packages/kbn-optimizer/src/optimizer/cache_keys.ts deleted file mode 100644 index a30dbe27dff08c..00000000000000 --- a/packages/kbn-optimizer/src/optimizer/cache_keys.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import Path from 'path'; -import Fs from 'fs'; - -import execa from 'execa'; -import { REPO_ROOT } from '@kbn/utils'; -import { diffStrings } from '@kbn/dev-utils'; - -import jsonStable from 'json-stable-stringify'; -import { ascending, CacheableWorkerConfig } from '../common'; - -import { getMtimes } from './get_mtimes'; -import { getChanges } from './get_changes'; -import { OptimizerConfig } from './optimizer_config'; - -const RELATIVE_DIR = 'packages/kbn-optimizer'; - -export function diffCacheKey(expected?: unknown, actual?: unknown) { - const expectedJson = jsonStable(expected, { - space: ' ', - }); - const actualJson = jsonStable(actual, { - space: ' ', - }); - - if (expectedJson === actualJson) { - return; - } - - return diffStrings(expectedJson, actualJson); -} - -export interface OptimizerCacheKey { - readonly lastCommit: string | undefined; - readonly workerConfig: CacheableWorkerConfig; - readonly deletedPaths: string[]; - readonly modifiedTimes: Record; -} - -async function getLastCommit() { - const { stdout } = await execa( - 'git', - ['log', '-n', '1', '--pretty=format:%H', '--', RELATIVE_DIR], - { - cwd: REPO_ROOT, - } - ); - - return stdout.trim() || undefined; -} - -export async function getOptimizerCacheKey(config: OptimizerConfig): Promise { - if (!Fs.existsSync(Path.resolve(REPO_ROOT, '.git'))) { - return { - lastCommit: undefined, - modifiedTimes: {}, - workerConfig: config.getCacheableWorkerConfig(), - deletedPaths: [], - }; - } - - const [changes, lastCommit] = await Promise.all([ - getChanges(RELATIVE_DIR), - getLastCommit(), - ] as const); - - const deletedPaths: string[] = []; - const modifiedPaths: string[] = []; - for (const [path, type] of changes) { - (type === 'deleted' ? deletedPaths : modifiedPaths).push(path); - } - - const cacheKeys: OptimizerCacheKey = { - lastCommit, - deletedPaths, - modifiedTimes: {} as Record, - workerConfig: config.getCacheableWorkerConfig(), - }; - - const mtimes = await getMtimes(modifiedPaths); - for (const [path, mtime] of Array.from(mtimes.entries()).sort(ascending((e) => e[0]))) { - if (typeof mtime === 'number') { - cacheKeys.modifiedTimes[path] = mtime; - } - } - - return cacheKeys; -} diff --git a/packages/kbn-optimizer/src/optimizer/diff_cache_key.ts b/packages/kbn-optimizer/src/optimizer/diff_cache_key.ts new file mode 100644 index 00000000000000..594e1e57efe7c2 --- /dev/null +++ b/packages/kbn-optimizer/src/optimizer/diff_cache_key.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { diffStrings } from '@kbn/dev-utils'; +import jsonStable from 'json-stable-stringify'; + +export function diffCacheKey(expected?: unknown, actual?: unknown) { + const expectedJson = jsonStable(expected, { + space: ' ', + }); + const actualJson = jsonStable(actual, { + space: ' ', + }); + + if (expectedJson === actualJson) { + return; + } + + return diffStrings(expectedJson, actualJson); +} diff --git a/packages/kbn-optimizer/src/optimizer/get_changes.test.ts b/packages/kbn-optimizer/src/optimizer/get_changes.test.ts deleted file mode 100644 index d1754248dba171..00000000000000 --- a/packages/kbn-optimizer/src/optimizer/get_changes.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -jest.mock('execa'); - -import { getChanges } from './get_changes'; -import { createAbsolutePathSerializer } from '@kbn/dev-utils'; -import { REPO_ROOT } from '@kbn/utils'; - -const execa: jest.Mock = jest.requireMock('execa'); - -expect.addSnapshotSerializer(createAbsolutePathSerializer()); - -it('parses git ls-files output', async () => { - expect.assertions(4); - - execa.mockImplementation((cmd, args, options) => { - expect(cmd).toBe('git'); - expect(args).toEqual(['ls-files', '-dmt', '--', 'foo/bar/x']); - expect(options).toEqual({ - cwd: REPO_ROOT, - }); - - return { - stdout: [ - 'C kbn-optimizer/package.json', - 'C kbn-optimizer/src/common/bundle.ts', - 'R kbn-optimizer/src/common/bundles.ts', - 'C kbn-optimizer/src/common/bundles.ts', - 'R kbn-optimizer/src/get_bundle_definitions.test.ts', - 'C kbn-optimizer/src/get_bundle_definitions.test.ts', - ].join('\n'), - }; - }); - - const changes = await getChanges('foo/bar/x'); - - expect(changes).toMatchInlineSnapshot(` - Map { - /kbn-optimizer/package.json => "modified", - /kbn-optimizer/src/common/bundle.ts => "modified", - /kbn-optimizer/src/common/bundles.ts => "deleted", - /kbn-optimizer/src/get_bundle_definitions.test.ts => "deleted", - } - `); -}); diff --git a/packages/kbn-optimizer/src/optimizer/get_changes.ts b/packages/kbn-optimizer/src/optimizer/get_changes.ts deleted file mode 100644 index b59f938eb8c37a..00000000000000 --- a/packages/kbn-optimizer/src/optimizer/get_changes.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import Path from 'path'; - -import execa from 'execa'; - -import { REPO_ROOT } from '@kbn/utils'; - -export type Changes = Map; - -/** - * get the changes in all the context directories (plugin public paths) - */ -export async function getChanges(relativeDir: string) { - const changes: Changes = new Map(); - - const { stdout } = await execa('git', ['ls-files', '-dmt', '--', relativeDir], { - cwd: REPO_ROOT, - }); - - const output = stdout.trim(); - - if (output) { - for (const line of output.split('\n')) { - const [tag, ...pathParts] = line.trim().split(' '); - const path = Path.resolve(REPO_ROOT, pathParts.join(' ')); - switch (tag) { - case 'M': - case 'C': - // for some reason ls-files returns deleted files as both deleted - // and modified, so make sure not to overwrite changes already - // tracked as "deleted" - if (changes.get(path) !== 'deleted') { - changes.set(path, 'modified'); - } - break; - - case 'R': - changes.set(path, 'deleted'); - break; - - default: - throw new Error(`unexpected path status ${tag} for path ${path}`); - } - } - } - - return changes; -} diff --git a/packages/kbn-optimizer/src/optimizer/get_mtimes.test.ts b/packages/kbn-optimizer/src/optimizer/get_mtimes.test.ts deleted file mode 100644 index 945b7049688aca..00000000000000 --- a/packages/kbn-optimizer/src/optimizer/get_mtimes.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -jest.mock('fs'); - -import { getMtimes } from './get_mtimes'; - -const { stat }: { stat: jest.Mock } = jest.requireMock('fs'); - -it('returns mtimes Map', async () => { - stat.mockImplementation((path, cb) => { - if (path.includes('missing')) { - const error = new Error('file not found'); - (error as any).code = 'ENOENT'; - cb(error); - } else { - cb(null, { - mtimeMs: 1234, - }); - } - }); - - await expect(getMtimes(['/foo/bar', '/foo/missing', '/foo/baz', '/foo/bar'])).resolves - .toMatchInlineSnapshot(` - Map { - "/foo/bar" => 1234, - "/foo/baz" => 1234, - } - `); -}); diff --git a/packages/kbn-optimizer/src/optimizer/get_mtimes.ts b/packages/kbn-optimizer/src/optimizer/get_mtimes.ts deleted file mode 100644 index e1810a3da92b7f..00000000000000 --- a/packages/kbn-optimizer/src/optimizer/get_mtimes.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import Fs from 'fs'; - -import * as Rx from 'rxjs'; -import { mergeMap, map, catchError } from 'rxjs/operators'; -import { allValuesFrom } from '../common'; - -const stat$ = Rx.bindNodeCallback(Fs.stat); - -/** - * get mtimes of referenced paths concurrently, limit concurrency to 100 - */ -export async function getMtimes(paths: Iterable) { - return new Map( - await allValuesFrom( - Rx.from(paths).pipe( - // map paths to [path, mtimeMs] entries with concurrency of - // 100 at a time, ignoring missing paths - mergeMap( - (path) => - stat$(path).pipe( - map((stat) => [path, stat.mtimeMs] as const), - catchError((error: any) => - error?.code === 'ENOENT' ? Rx.EMPTY : Rx.throwError(error) - ) - ), - 100 - ) - ) - ) - ); -} diff --git a/packages/kbn-optimizer/src/optimizer/index.ts b/packages/kbn-optimizer/src/optimizer/index.ts index a2fefb395e7204..aeb2b7621ca684 100644 --- a/packages/kbn-optimizer/src/optimizer/index.ts +++ b/packages/kbn-optimizer/src/optimizer/index.ts @@ -9,7 +9,8 @@ export * from './optimizer_config'; export type { WorkerStdio } from './observe_worker'; export * from './optimizer_state'; -export * from './cache_keys'; +export * from './diff_cache_key'; +export * from './optimizer_cache_key'; export * from './watch_bundles_for_changes'; export * from './run_workers'; export * from './bundle_cache'; diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_built_paths.ts b/packages/kbn-optimizer/src/optimizer/optimizer_built_paths.ts new file mode 100644 index 00000000000000..294c3e835a3bd0 --- /dev/null +++ b/packages/kbn-optimizer/src/optimizer/optimizer_built_paths.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import globby from 'globby'; + +import { ascending } from '../common'; + +export async function getOptimizerBuiltPaths() { + return ( + await globby( + ['**/*', '!**/{__fixtures__,__snapshots__,integration_tests,babel_runtime_helpers,node}/**'], + { + cwd: Path.resolve(__dirname, '../'), + absolute: true, + } + ) + ) + .slice() + .sort(ascending((p) => p)); +} diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_cache_key.test.ts b/packages/kbn-optimizer/src/optimizer/optimizer_cache_key.test.ts new file mode 100644 index 00000000000000..3670a6af2cbe5c --- /dev/null +++ b/packages/kbn-optimizer/src/optimizer/optimizer_cache_key.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { REPO_ROOT } from '@kbn/utils'; +import { createAbsolutePathSerializer } from '@kbn/dev-utils'; + +import { getOptimizerCacheKey } from './optimizer_cache_key'; +import { OptimizerConfig } from './optimizer_config'; + +jest.mock('../common/hashes', () => { + return { + Hashes: class MockHashes { + static ofFiles = jest.fn(() => { + return new MockHashes(); + }); + + cacheToJson() { + return { foo: 'bar' }; + } + }, + }; +}); + +jest.mock('./optimizer_built_paths', () => { + return { + getOptimizerBuiltPaths: () => ['/built/foo.js', '/built/bar.js'], + }; +}); + +const { Hashes: MockHashes } = jest.requireMock('../common/hashes'); + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); + +describe('getOptimizerCacheKey()', () => { + it('determines checksums of all optimizer files', async () => { + const config = OptimizerConfig.create({ + repoRoot: REPO_ROOT, + }); + + const key = await getOptimizerCacheKey(config); + + expect(MockHashes.ofFiles).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + "/built/foo.js", + "/built/bar.js", + ], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": MockHashes {}, + }, + ], + } + `); + + expect(key).toMatchInlineSnapshot(` + Object { + "checksums": Object { + "foo": "bar", + }, + "workerConfig": Object { + "browserslistEnv": "dev", + "dist": false, + "optimizerCacheKey": "♻", + "repoRoot": , + "themeTags": Array [ + "v8dark", + "v8light", + ], + }, + } + `); + }); +}); diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_cache_key.ts b/packages/kbn-optimizer/src/optimizer/optimizer_cache_key.ts new file mode 100644 index 00000000000000..1c955e18c10ce4 --- /dev/null +++ b/packages/kbn-optimizer/src/optimizer/optimizer_cache_key.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CacheableWorkerConfig, Hashes } from '../common'; +import { OptimizerConfig } from './optimizer_config'; +import { getOptimizerBuiltPaths } from './optimizer_built_paths'; + +export interface OptimizerCacheKey { + readonly workerConfig: CacheableWorkerConfig; + readonly checksums: Record; +} + +/** + * Hash the contents of the built files that the optimizer is currently running. This allows us to + * invalidate the optimizer results if someone forgets to bootstrap, or changes the optimizer source files + */ +export async function getOptimizerCacheKey(config: OptimizerConfig): Promise { + const hashes = await Hashes.ofFiles(await getOptimizerBuiltPaths()); + + return { + checksums: hashes.cacheToJson(), + workerConfig: config.getCacheableWorkerConfig(), + }; +} diff --git a/packages/kbn-optimizer/src/optimizer/watcher.ts b/packages/kbn-optimizer/src/optimizer/watcher.ts index 65958d6669f73b..64a2f376ad4496 100644 --- a/packages/kbn-optimizer/src/optimizer/watcher.ts +++ b/packages/kbn-optimizer/src/optimizer/watcher.ts @@ -63,7 +63,7 @@ export class Watcher { (changes): Changes => ({ type: 'changes', bundles: bundles.filter((bundle) => { - const referencedFiles = bundle.cache.getReferencedFiles(); + const referencedFiles = bundle.cache.getReferencedPaths(); return changes.some((change) => referencedFiles?.includes(change)); }), }) @@ -73,15 +73,15 @@ export class Watcher { // call watchpack.watch after listerners are setup Rx.defer(() => { - const watchPaths: string[] = []; + const watchPaths = new Set(); for (const bundle of bundles) { - for (const path of bundle.cache.getReferencedFiles() || []) { - watchPaths.push(path); + for (const path of bundle.cache.getReferencedPaths() || []) { + watchPaths.add(path); } } - this.watchpack.watch(watchPaths, [], startTime); + this.watchpack.watch(Array.from(watchPaths), [], startTime); return Rx.EMPTY; }) ); diff --git a/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts b/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts index 18d260ee72cc1f..2c6569ef623213 100644 --- a/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts +++ b/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts @@ -12,7 +12,7 @@ import { inspect } from 'util'; import webpack from 'webpack'; -import { Bundle, WorkerConfig, ascending, parseFilePath } from '../common'; +import { Bundle, WorkerConfig, ascending, parseFilePath, Hashes } from '../common'; import { BundleRefModule } from './bundle_ref_module'; import { isExternalModule, @@ -59,12 +59,29 @@ export class PopulateBundleCachePlugin { }, (compilation) => { const bundleRefExportIds: string[] = []; - const referencedFiles = new Set(); let moduleCount = 0; let workUnits = compilation.fileDependencies.size; + const paths = new Set(); + const rawHashes = new Map(); + const addReferenced = (path: string) => { + if (paths.has(path)) { + return; + } + + paths.add(path); + let content: Buffer; + try { + content = compiler.inputFileSystem.readFileSync(path); + } catch { + return rawHashes.set(path, null); + } + + return rawHashes.set(path, Hashes.hash(content)); + }; + if (bundle.manifestPath) { - referencedFiles.add(bundle.manifestPath); + addReferenced(bundle.manifestPath); } for (const module of compilation.modules) { @@ -84,13 +101,13 @@ export class PopulateBundleCachePlugin { } if (!parsedPath.dirs.includes('node_modules')) { - referencedFiles.add(path); + addReferenced(path); if (path.endsWith('.scss')) { workUnits += EXTRA_SCSS_WORK_UNITS; for (const depPath of module.buildInfo.fileDependencies) { - referencedFiles.add(depPath); + addReferenced(depPath); } } @@ -105,7 +122,7 @@ export class PopulateBundleCachePlugin { 'package.json' ); - referencedFiles.add(isBazelPackage(pkgJsonPath) ? path : pkgJsonPath); + addReferenced(isBazelPackage(pkgJsonPath) ? path : pkgJsonPath); continue; } @@ -126,28 +143,15 @@ export class PopulateBundleCachePlugin { throw new Error(`Unexpected module type: ${inspect(module)}`); } - const files = Array.from(referencedFiles).sort(ascending((p) => p)); - const mtimes = new Map( - files.map((path): [string, number | undefined] => { - try { - return [path, compiler.inputFileSystem.statSync(path)?.mtimeMs]; - } catch (error) { - if (error?.code === 'ENOENT') { - return [path, undefined]; - } - - throw error; - } - }) - ); + const referencedPaths = Array.from(paths).sort(ascending((p) => p)); bundle.cache.set({ bundleRefExportIds: bundleRefExportIds.sort(ascending((p) => p)), optimizerCacheKey: workerConfig.optimizerCacheKey, - cacheKey: bundle.createCacheKey(files, mtimes), + cacheKey: bundle.createCacheKey(referencedPaths, new Hashes(rawHashes)), moduleCount, workUnits, - files, + referencedPaths, }); // write the cache to the compilation so that it isn't cleaned by clean-webpack-plugin From e075a1e430dc09ca3a8591ee51fb936ad07f9f6a Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Tue, 15 Mar 2022 18:53:46 -0400 Subject: [PATCH 12/39] [Security Solution][Endpoint] Tech Debt: Refactor tests for ArtifactListPage component into smaller files (#127648) * Create reusable artifact list page test rendering setup * Split up tests into individual files --- .../artifact_list_page.test.tsx | 677 +----------------- .../components/artifact_delete_modal.test.ts | 134 ++++ .../components/artifact_flyout.test.tsx | 424 +++++++++++ .../components/no_data_empty_state.test.ts | 113 +++ .../components/artifact_list_page/mocks.tsx | 148 ++++ 5 files changed, 834 insertions(+), 662 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx index 5c1b6e5128a4a2..383a72031ed76c 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx @@ -5,23 +5,18 @@ * 2.0. */ -import { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint'; -import React from 'react'; -import { trustedAppsAllHttpMocks, TrustedAppsGetListHttpMocksInterface } from '../../pages/mocks'; -import { ArtifactListPage, ArtifactListPageProps } from './artifact_list_page'; -import { TrustedAppsApiClient } from '../../pages/trusted_apps/service/trusted_apps_api_client'; -import { artifactListPageLabels } from './translations'; -import { act, fireEvent, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react'; +import { AppContextTestRender } from '../../../common/mock/endpoint'; +import { trustedAppsAllHttpMocks } from '../../pages/mocks'; +import { ArtifactListPageProps } from './artifact_list_page'; +import { act, fireEvent, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { ArtifactFormComponentProps } from './types'; -import type { HttpFetchOptionsWithPath } from 'kibana/public'; -import { ExceptionsListItemGenerator } from '../../../../common/endpoint/data_generators/exceptions_list_item_generator'; -import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../../common/endpoint/service/artifacts'; -import { useUserPrivileges as _useUserPrivileges } from '../../../common/components/user_privileges'; -import { getEndpointPrivilegesInitialStateMock } from '../../../common/components/user_privileges/endpoint/mocks'; +import { + getArtifactListPageRenderingSetup, + getDeferred, + ArtifactListPageRenderingSetup, +} from './mocks'; jest.mock('../../../common/components/user_privileges'); -const useUserPrivileges = _useUserPrivileges as jest.Mock; describe('When using the ArtifactListPage component', () => { let render: ( @@ -29,79 +24,15 @@ describe('When using the ArtifactListPage component', () => { ) => ReturnType; let renderResult: ReturnType; let history: AppContextTestRender['history']; - let coreStart: AppContextTestRender['coreStart']; let mockedApi: ReturnType; - let FormComponentMock: jest.Mock>; + let getFirstCard: ArtifactListPageRenderingSetup['getFirstCard']; - interface DeferredInterface { - promise: Promise; - resolve: (data: T) => void; - reject: (e: Error) => void; - } - - const getDeferred = function (): DeferredInterface { - let resolve: DeferredInterface['resolve']; - let reject: DeferredInterface['reject']; - - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - - // @ts-ignore - return { promise, resolve, reject }; - }; + beforeEach(() => { + const renderSetup = getArtifactListPageRenderingSetup(); - /** - * Returns the props object that the Form component was last called with - */ - const getLastFormComponentProps = (): ArtifactFormComponentProps => { - return FormComponentMock.mock.calls[FormComponentMock.mock.calls.length - 1][0]; - }; + ({ history, mockedApi, getFirstCard } = renderSetup); - beforeEach(() => { - const mockedContext = createAppRootMockRenderer(); - - ({ history, coreStart } = mockedContext); - mockedApi = trustedAppsAllHttpMocks(coreStart.http); - - const apiClient = new TrustedAppsApiClient(coreStart.http); - const labels = { ...artifactListPageLabels }; - - FormComponentMock = jest.fn((({ mode, error, disabled }: ArtifactFormComponentProps) => { - return ( -
-
{`${mode} form`}
-
{`Is Disabled: ${disabled}`}
- {error && ( - <> -
{error.message}
-
{JSON.stringify(error.body)}
- - )} -
- ); - }) as unknown as jest.Mock>); - - render = (props: Partial = {}) => { - return (renderResult = mockedContext.render( - - )); - }; - - // Ensure user privileges are reset - useUserPrivileges.mockReturnValue({ - ...useUserPrivileges(), - endpointPrivileges: getEndpointPrivilegesInitialStateMock(), - }); + render = (props = {}) => (renderResult = renderSetup.renderArtifactListPage(props)); }); it('should display a loader while determining which view to show', async () => { @@ -122,502 +53,9 @@ describe('When using the ArtifactListPage component', () => { await waitForElementToBeRemoved(loader); }); - describe('and NO data exists', () => { - let renderWithNoData: () => ReturnType; - let originalListApiResponseProvider: TrustedAppsGetListHttpMocksInterface['trustedAppsList']; - - beforeEach(() => { - originalListApiResponseProvider = - mockedApi.responseProvider.trustedAppsList.getMockImplementation()!; - - renderWithNoData = () => { - mockedApi.responseProvider.trustedAppsList.mockReturnValue({ - data: [], - page: 1, - per_page: 10, - total: 0, - }); - - render(); - - return renderResult; - }; - }); - - it('should display empty state', async () => { - renderWithNoData(); - - await waitFor(async () => { - expect(renderResult.getByTestId('testPage-emptyState')); - }); - }); - - it('should hide page headers', async () => { - renderWithNoData(); - - expect(renderResult.queryByTestId('header-page-title')).toBe(null); - }); - - it('should open create flyout when primary button is clicked', async () => { - renderWithNoData(); - const addButton = await renderResult.findByTestId('testPage-emptyState-addButton'); - - act(() => { - userEvent.click(addButton); - }); - - expect(renderResult.getByTestId('testPage-flyout')).toBeTruthy(); - expect(history.location.search).toMatch(/show=create/); - }); - - describe('and the first item is created', () => { - it('should show the list after creating first item and remove empty state', async () => { - renderWithNoData(); - const addButton = await renderResult.findByTestId('testPage-emptyState-addButton'); - - act(() => { - userEvent.click(addButton); - }); - - await waitFor(async () => { - expect(renderResult.getByTestId('testPage-flyout')); - }); - - // indicate form is valid - act(() => { - const lastProps = getLastFormComponentProps(); - lastProps.onChange({ item: { ...lastProps.item, name: 'some name' }, isValid: true }); - }); - - mockedApi.responseProvider.trustedAppsList.mockImplementation( - originalListApiResponseProvider - ); - - // Submit form - act(() => { - userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); - }); - - // wait for the list to show up - await act(async () => { - await waitFor(() => { - expect(renderResult.getByTestId('testPage-list')).toBeTruthy(); - }); - }); - }); - }); - }); - - describe('and the flyout is opened', () => { - let renderAndWaitForFlyout: ( - props?: Partial - ) => Promise>; - - beforeEach(async () => { - history.push('somepage?show=create'); - - renderAndWaitForFlyout = async (...props) => { - render(...props); - - await waitFor(async () => { - expect(renderResult.getByTestId('testPage-flyout')); - }); - - return renderResult; - }; - }); - - it('should display `Cancel` button enabled', async () => { - await renderAndWaitForFlyout(); - - expect(renderResult.getByTestId('testPage-flyout-cancelButton')).toBeEnabled(); - }); - - it('should display `Submit` button as disabled', async () => { - await renderAndWaitForFlyout(); - - expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); - }); - - it.each([ - ['Cancel', 'testPage-flyout-cancelButton'], - ['Close', 'euiFlyoutCloseButton'], - ])('should close flyout when `%s` button is clicked', async (_, testId) => { - await renderAndWaitForFlyout(); - - act(() => { - userEvent.click(renderResult.getByTestId(testId)); - }); - - expect(renderResult.queryByTestId('testPage-flyout')).toBeNull(); - expect(history.location.search).toEqual(''); - }); - - it('should pass to the Form component the expected props', async () => { - await renderAndWaitForFlyout(); - - expect(FormComponentMock).toHaveBeenLastCalledWith( - { - disabled: false, - error: undefined, - item: { - comments: [], - description: '', - entries: [], - item_id: undefined, - list_id: 'endpoint_trusted_apps', - meta: expect.any(Object), - name: '', - namespace_type: 'agnostic', - os_types: ['windows'], - tags: ['policy:all'], - type: 'simple', - }, - mode: 'create', - onChange: expect.any(Function), - }, - expect.anything() - ); - }); - - describe('and form data is valid', () => { - beforeEach(async () => { - const _renderAndWaitForFlyout = renderAndWaitForFlyout; - - // Override renderAndWaitForFlyout to also set the form data as "valid" - renderAndWaitForFlyout = async (...props) => { - await _renderAndWaitForFlyout(...props); - - act(() => { - const lastProps = getLastFormComponentProps(); - lastProps.onChange({ item: { ...lastProps.item, name: 'some name' }, isValid: true }); - }); - - return renderResult; - }; - }); - - it('should enable the `Submit` button', async () => { - await renderAndWaitForFlyout(); - - expect(renderResult.getByTestId('testPage-flyout-submitButton')).toBeEnabled(); - }); - - describe('and user clicks submit', () => { - let releaseApiUpdateResponse: () => void; - let getByTestId: typeof renderResult['getByTestId']; - - beforeEach(async () => { - await renderAndWaitForFlyout(); - - getByTestId = renderResult.getByTestId; - - // Mock a delay into the create api http call - const deferrable = getDeferred(); - mockedApi.responseProvider.trustedAppCreate.mockDelay.mockReturnValue(deferrable.promise); - releaseApiUpdateResponse = deferrable.resolve; - - act(() => { - userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); - }); - }); - - afterEach(() => { - if (releaseApiUpdateResponse) { - releaseApiUpdateResponse(); - } - }); - - it('should disable all buttons while an update is in flight', () => { - expect(getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); - expect(getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); - }); - - it('should display loading indicator on Submit while an update is in flight', () => { - expect( - getByTestId('testPage-flyout-submitButton').querySelector('.euiLoadingSpinner') - ).toBeTruthy(); - }); - - it('should pass `disabled=true` to the Form component while an update is in flight', () => { - expect(getLastFormComponentProps().disabled).toBe(true); - }); - }); - - describe('and submit is successful', () => { - beforeEach(async () => { - await renderAndWaitForFlyout(); - - act(() => { - userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); - }); - - await act(async () => { - await waitFor(() => { - expect(renderResult.queryByTestId('testPage-flyout')).toBeNull(); - }); - }); - }); - - it('should show a success toast', async () => { - expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( - '"some name" has been added.' - ); - }); - - it('should clear the URL params', () => { - expect(location.search).toBe(''); - }); - }); - - describe('and submit fails', () => { - beforeEach(async () => { - const _renderAndWaitForFlyout = renderAndWaitForFlyout; - - renderAndWaitForFlyout = async (...args) => { - mockedApi.responseProvider.trustedAppCreate.mockImplementation(() => { - throw new Error('oh oh. no good!'); - }); - - await _renderAndWaitForFlyout(...args); - - act(() => { - userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); - }); - - await act(async () => { - await waitFor(() => - expect(mockedApi.responseProvider.trustedAppCreate).toHaveBeenCalled() - ); - }); - - return renderResult; - }; - }); - - // FIXME:PT investigate test failure - // (I don't understand why its failing... All assertions are successful -- HELP!) - it.skip('should re-enable `Cancel` and `Submit` buttons', async () => { - await renderAndWaitForFlyout(); - - expect(renderResult.getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); - - expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); - }); - - // FIXME:PT investigate test failure - // (I don't understand why its failing... All assertions are successful -- HELP!) - it.skip('should pass error along to the Form component and reset disabled back to `false`', async () => { - await renderAndWaitForFlyout(); - const lastFormProps = getLastFormComponentProps(); - - expect(lastFormProps.error).toBeInstanceOf(Error); - expect(lastFormProps.disabled).toBe(false); - }); - }); - - describe('and a custom Submit handler is used', () => { - let handleSubmitCallback: jest.Mock; - let releaseSuccessSubmit: () => void; - let releaseFailureSubmit: () => void; - - beforeEach(async () => { - const deferred = getDeferred(); - releaseSuccessSubmit = () => act(() => deferred.resolve()); - releaseFailureSubmit = () => act(() => deferred.reject(new Error('oh oh. No good'))); - - handleSubmitCallback = jest.fn(async (item) => { - await deferred.promise; - - return new ExceptionsListItemGenerator().generateTrustedApp(item); - }); - - await renderAndWaitForFlyout({ onFormSubmit: handleSubmitCallback }); - - act(() => { - userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); - }); - }); - - afterEach(() => { - if (releaseSuccessSubmit) { - releaseSuccessSubmit(); - } - }); - - it('should use custom submit handler when submit button is used', async () => { - expect(handleSubmitCallback).toHaveBeenCalled(); - - expect(renderResult.getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); - - expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); - }); - - it('should catch and show error if one is encountered', async () => { - releaseFailureSubmit(); - await waitFor(() => { - expect(renderResult.getByTestId('formError')).toBeTruthy(); - }); - }); - - it('should show a success toast', async () => { - releaseSuccessSubmit(); - - await waitFor(() => { - expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); - }); - - expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( - '"some name" has been added.' - ); - }); - - it('should clear the URL params', () => { - releaseSuccessSubmit(); - - expect(location.search).toBe(''); - }); - }); - }); - - describe('and in Edit mode', () => { - beforeEach(async () => { - history.push('somepage?show=edit&itemId=123'); - }); - - it('should show loader while initializing in edit mode', async () => { - const deferred = getDeferred(); - mockedApi.responseProvider.trustedApp.mockDelay.mockReturnValue(deferred.promise); - - const { getByTestId } = await renderAndWaitForFlyout(); - - // The loader should be shown and the flyout footer should not be shown - expect(getByTestId('testPage-flyout-loader')).toBeTruthy(); - expect(() => getByTestId('testPage-flyout-cancelButton')).toThrow(); - expect(() => getByTestId('testPage-flyout-submitButton')).toThrow(); - - // The Form should not yet have been rendered - expect(FormComponentMock).not.toHaveBeenCalled(); - - act(() => deferred.resolve()); - - // we should call the GET API with the id provided - await waitFor(() => { - expect(mockedApi.responseProvider.trustedApp).toHaveBeenLastCalledWith( - expect.objectContaining({ - path: expect.any(String), - query: expect.objectContaining({ - item_id: '123', - }), - }) - ); - }); - }); - - it('should provide Form component with the item for edit', async () => { - const { getByTestId } = await renderAndWaitForFlyout(); - - await act(async () => { - await waitFor(() => { - expect(getByTestId('formMock')).toBeTruthy(); - }); - }); - - expect(getLastFormComponentProps().item).toEqual({ - ...mockedApi.responseProvider.trustedApp({ - query: { item_id: '123' }, - } as unknown as HttpFetchOptionsWithPath), - created_at: expect.any(String), - }); - }); - - it('should show error toast and close flyout if item for edit does not exist', async () => { - mockedApi.responseProvider.trustedApp.mockImplementation(() => { - throw new Error('does not exist'); - }); - - await renderAndWaitForFlyout(); - - await act(async () => { - await waitFor(() => { - expect(mockedApi.responseProvider.trustedApp).toHaveBeenCalled(); - }); - }); - - expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledWith( - 'Failed to retrieve item for edit. Reason: does not exist' - ); - }); - - it('should not show the expired license callout', async () => { - const { queryByTestId, getByTestId } = await renderAndWaitForFlyout(); - - await act(async () => { - await waitFor(() => { - expect(getByTestId('formMock')).toBeTruthy(); - }); - }); - - expect(queryByTestId('testPage-flyout-expiredLicenseCallout')).not.toBeTruthy(); - }); - - it('should show expired license warning when unsupported features are being used (downgrade scenario)', async () => { - // make the API return a policy specific item - const _generateResponse = mockedApi.responseProvider.trustedApp.getMockImplementation()!; - mockedApi.responseProvider.trustedApp.mockImplementation((params) => { - return { - ..._generateResponse(params), - tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${123}`], - }; - }); - - useUserPrivileges.mockReturnValue({ - ...useUserPrivileges(), - endpointPrivileges: getEndpointPrivilegesInitialStateMock({ - canCreateArtifactsByPolicy: false, - }), - }); - - const { getByTestId } = await renderAndWaitForFlyout(); - - await act(async () => { - await waitFor(() => { - expect(getByTestId('formMock')).toBeTruthy(); - }); - }); - - expect(getByTestId('testPage-flyout-expiredLicenseCallout')).toBeTruthy(); - }); - }); - }); - describe('and data exists', () => { let renderWithListData: () => Promise>; - const getFirstCard = async ({ - showActions = false, - }: Partial<{ showActions: boolean }> = {}): Promise => { - const cards = await renderResult.findAllByTestId('testPage-card'); - - if (cards.length === 0) { - throw new Error('No cards found!'); - } - - const card = cards[0]; - - if (showActions) { - await act(async () => { - userEvent.click(within(card).getByTestId('testPage-card-header-actions-button')); - - await waitFor(() => { - expect(renderResult.getByTestId('testPage-card-header-actions-contextMenuPanel')); - }); - }); - } - - return card; - }; - beforeEach(async () => { renderWithListData = async () => { render(); @@ -721,91 +159,6 @@ describe('When using the ArtifactListPage component', () => { expect(getByTestId('testPage-deleteModal')).toBeTruthy(); }); - - describe('and interacting with the deletion modal', () => { - let cancelButton: HTMLButtonElement; - let submitButton: HTMLButtonElement; - - beforeEach(async () => { - await renderWithListData(); - await clickCardAction('delete'); - - cancelButton = renderResult.getByTestId( - 'testPage-deleteModal-cancelButton' - ) as HTMLButtonElement; - submitButton = renderResult.getByTestId( - 'testPage-deleteModal-submitButton' - ) as HTMLButtonElement; - }); - - it('should show Cancel and Delete buttons enabled', async () => { - expect(cancelButton).toBeEnabled(); - expect(submitButton).toBeEnabled(); - }); - - it('should close modal if Cancel/Close buttons are clicked', async () => { - userEvent.click(cancelButton); - - expect(renderResult.queryByTestId('testPage-deleteModal')).toBeNull(); - }); - - it('should prevent modal from being closed while deletion is in flight', async () => { - const deferred = getDeferred(); - mockedApi.responseProvider.trustedAppDelete.mockDelay.mockReturnValue(deferred.promise); - - act(() => { - userEvent.click(submitButton); - }); - - await waitFor(() => { - expect(cancelButton).toBeEnabled(); - expect(submitButton).toBeEnabled(); - }); - - deferred.resolve(); // cleanup - }); - - it('should show success toast if deleted successfully', async () => { - act(() => { - userEvent.click(submitButton); - }); - - await act(async () => { - await waitFor(() => { - expect(mockedApi.responseProvider.trustedAppDelete).toHaveBeenCalled(); - }); - }); - - expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( - expect.stringMatching(/ has been removed$/) - ); - }); - - // FIXME:PT investigate test failure - // (I don't understand why its failing... All assertions are successful -- HELP!) - it.skip('should show error toast if deletion failed', async () => { - mockedApi.responseProvider.trustedAppDelete.mockImplementation(() => { - throw new Error('oh oh'); - }); - - act(() => { - userEvent.click(submitButton); - }); - - await act(async () => { - await waitFor(() => { - expect(mockedApi.responseProvider.trustedAppDelete).toHaveBeenCalled(); - }); - }); - - expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - expect.stringMatching(/^Unable to remove .*\. Reason: oh oh/) - ); - expect(renderResult.getByTestId('testPage-deleteModal')).toBeTruthy(); - expect(cancelButton).toBeEnabled(); - expect(submitButton).toBeEnabled(); - }); - }); }); describe('and search bar is used', () => { @@ -878,7 +231,7 @@ describe('When using the ArtifactListPage component', () => { }); }); - it('should show a no results found message if filter did not return any results', async () => { + it('should show a no results found message if filter did not return` any results', async () => { let apiNoResultsDone = false; mockedApi.responseProvider.trustedAppsList.mockImplementationOnce(() => { apiNoResultsDone = true; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts new file mode 100644 index 00000000000000..d696906bcf5eab --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AppContextTestRender } from '../../../../common/mock/endpoint'; +import { trustedAppsAllHttpMocks } from '../../../pages/mocks'; +import { + ArtifactListPageRenderingSetup, + getArtifactListPageRenderingSetup, + getDeferred, +} from '../mocks'; +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +describe('When displaying the Delete artfifact modal in the Artifact List Page', () => { + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let coreStart: AppContextTestRender['coreStart']; + let mockedApi: ReturnType; + let getFirstCard: ArtifactListPageRenderingSetup['getFirstCard']; + let cancelButton: HTMLButtonElement; + let submitButton: HTMLButtonElement; + + const clickCardAction = async (action: 'edit' | 'delete') => { + await getFirstCard({ showActions: true }); + act(() => { + switch (action) { + case 'delete': + userEvent.click(renderResult.getByTestId('testPage-card-cardDeleteAction')); + break; + + case 'edit': + userEvent.click(renderResult.getByTestId('testPage-card-cardEditAction')); + break; + } + }); + }; + + beforeEach(async () => { + const renderSetup = getArtifactListPageRenderingSetup(); + + ({ history, coreStart, mockedApi, getFirstCard } = renderSetup); + + history.push('somepage?show=create'); + + renderResult = renderSetup.renderArtifactListPage(); + + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId('testPage-list')).toBeTruthy(); + }); + }); + + await clickCardAction('delete'); + + cancelButton = renderResult.getByTestId( + 'testPage-deleteModal-cancelButton' + ) as HTMLButtonElement; + submitButton = renderResult.getByTestId( + 'testPage-deleteModal-submitButton' + ) as HTMLButtonElement; + }); + + it('should show Cancel and Delete buttons enabled', async () => { + expect(cancelButton).toBeEnabled(); + expect(submitButton).toBeEnabled(); + }); + + it('should close modal if Cancel/Close buttons are clicked', async () => { + userEvent.click(cancelButton); + + expect(renderResult.queryByTestId('testPage-deleteModal')).toBeNull(); + }); + + it('should prevent modal from being closed while deletion is in flight', async () => { + const deferred = getDeferred(); + mockedApi.responseProvider.trustedAppDelete.mockDelay.mockReturnValue(deferred.promise); + + act(() => { + userEvent.click(submitButton); + }); + + await waitFor(() => { + expect(cancelButton).toBeEnabled(); + expect(submitButton).toBeEnabled(); + }); + + deferred.resolve(); // cleanup + }); + + it('should show success toast if deleted successfully', async () => { + act(() => { + userEvent.click(submitButton); + }); + + await act(async () => { + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppDelete).toHaveBeenCalled(); + }); + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + expect.stringMatching(/ has been removed$/) + ); + }); + + // FIXME:PT investigate test failure + // (I don't understand why its failing... All assertions are successful -- HELP!) + it.skip('should show error toast if deletion failed', async () => { + mockedApi.responseProvider.trustedAppDelete.mockImplementation(() => { + throw new Error('oh oh'); + }); + + act(() => { + userEvent.click(submitButton); + }); + + await act(async () => { + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppDelete).toHaveBeenCalled(); + }); + }); + + expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( + expect.stringMatching(/^Unable to remove .*\. Reason: oh oh/) + ); + expect(renderResult.getByTestId('testPage-deleteModal')).toBeTruthy(); + expect(cancelButton).toBeEnabled(); + expect(submitButton).toBeEnabled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx new file mode 100644 index 00000000000000..743f71bfead05a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx @@ -0,0 +1,424 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ArtifactListPageProps } from '../artifact_list_page'; +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getArtifactListPageRenderingSetup, getDeferred, getFormComponentMock } from '../mocks'; +import { ExceptionsListItemGenerator } from '../../../../../common/endpoint/data_generators/exceptions_list_item_generator'; +import { HttpFetchOptionsWithPath } from 'kibana/public'; +import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../../../common/endpoint/service/artifacts'; +import { getEndpointPrivilegesInitialStateMock } from '../../../../common/components/user_privileges/endpoint/mocks'; +import { AppContextTestRender } from '../../../../common/mock/endpoint'; +import { trustedAppsAllHttpMocks } from '../../../pages/mocks'; +import { useUserPrivileges as _useUserPrivileges } from '../../../../common/components/user_privileges'; + +jest.mock('../../../../common/components/user_privileges'); +const useUserPrivileges = _useUserPrivileges as jest.Mock; + +describe('When the flyout is opened in the ArtifactListPage component', () => { + let render: ( + props?: Partial + ) => Promise>; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let coreStart: AppContextTestRender['coreStart']; + let mockedApi: ReturnType; + let FormComponentMock: ReturnType['FormComponentMock']; + let getLastFormComponentProps: ReturnType< + typeof getFormComponentMock + >['getLastFormComponentProps']; + + beforeEach(() => { + const renderSetup = getArtifactListPageRenderingSetup(); + + ({ history, coreStart, mockedApi, FormComponentMock, getLastFormComponentProps } = renderSetup); + + history.push('somepage?show=create'); + + render = async (props = {}) => { + renderResult = renderSetup.renderArtifactListPage(props); + + await waitFor(async () => { + expect(renderResult.getByTestId('testPage-flyout')); + }); + + return renderResult; + }; + }); + + afterEach(() => { + // Ensure user privileges are reset + useUserPrivileges.mockReturnValue({ + ...useUserPrivileges(), + endpointPrivileges: getEndpointPrivilegesInitialStateMock(), + }); + }); + + it('should display `Cancel` button enabled', async () => { + await render(); + + expect(renderResult.getByTestId('testPage-flyout-cancelButton')).toBeEnabled(); + }); + + it('should display `Submit` button as disabled', async () => { + await render(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + it.each([ + ['Cancel', 'testPage-flyout-cancelButton'], + ['Close', 'euiFlyoutCloseButton'], + ])('should close flyout when `%s` button is clicked', async (_, testId) => { + await render(); + + act(() => { + userEvent.click(renderResult.getByTestId(testId)); + }); + + expect(renderResult.queryByTestId('testPage-flyout')).toBeNull(); + expect(history.location.search).toEqual(''); + }); + + it('should pass to the Form component the expected props', async () => { + await render(); + + expect(FormComponentMock).toHaveBeenLastCalledWith( + { + disabled: false, + error: undefined, + item: { + comments: [], + description: '', + entries: [], + item_id: undefined, + list_id: 'endpoint_trusted_apps', + meta: expect.any(Object), + name: '', + namespace_type: 'agnostic', + os_types: ['windows'], + tags: ['policy:all'], + type: 'simple', + }, + mode: 'create', + onChange: expect.any(Function), + }, + expect.anything() + ); + }); + + describe('and form data is valid', () => { + beforeEach(async () => { + const _renderAndWaitForFlyout = render; + + // Override renderAndWaitForFlyout to also set the form data as "valid" + render = async (...props) => { + await _renderAndWaitForFlyout(...props); + + act(() => { + const lastProps = getLastFormComponentProps(); + lastProps.onChange({ item: { ...lastProps.item, name: 'some name' }, isValid: true }); + }); + + return renderResult; + }; + }); + + it('should enable the `Submit` button', async () => { + await render(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).toBeEnabled(); + }); + + describe('and user clicks submit', () => { + let releaseApiUpdateResponse: () => void; + let getByTestId: typeof renderResult['getByTestId']; + + beforeEach(async () => { + await render(); + + getByTestId = renderResult.getByTestId; + + // Mock a delay into the create api http call + const deferrable = getDeferred(); + mockedApi.responseProvider.trustedAppCreate.mockDelay.mockReturnValue(deferrable.promise); + releaseApiUpdateResponse = deferrable.resolve; + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + }); + + afterEach(() => { + if (releaseApiUpdateResponse) { + releaseApiUpdateResponse(); + } + }); + + it('should disable all buttons while an update is in flight', () => { + expect(getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); + expect(getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + it('should display loading indicator on Submit while an update is in flight', () => { + expect( + getByTestId('testPage-flyout-submitButton').querySelector('.euiLoadingSpinner') + ).toBeTruthy(); + }); + + it('should pass `disabled=true` to the Form component while an update is in flight', () => { + expect(getLastFormComponentProps().disabled).toBe(true); + }); + }); + + describe('and submit is successful', () => { + beforeEach(async () => { + await render(); + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + + await act(async () => { + await waitFor(() => { + expect(renderResult.queryByTestId('testPage-flyout')).toBeNull(); + }); + }); + }); + + it('should show a success toast', async () => { + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + '"some name" has been added.' + ); + }); + + it('should clear the URL params', () => { + expect(location.search).toBe(''); + }); + }); + + describe('and submit fails', () => { + beforeEach(async () => { + const _renderAndWaitForFlyout = render; + + render = async (...args) => { + mockedApi.responseProvider.trustedAppCreate.mockImplementation(() => { + throw new Error('oh oh. no good!'); + }); + + await _renderAndWaitForFlyout(...args); + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + + await act(async () => { + await waitFor(() => + expect(mockedApi.responseProvider.trustedAppCreate).toHaveBeenCalled() + ); + }); + + return renderResult; + }; + }); + + // FIXME:PT investigate test failure + // (I don't understand why its failing... All assertions are successful -- HELP!) + it.skip('should re-enable `Cancel` and `Submit` buttons', async () => { + await render(); + + expect(renderResult.getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + // FIXME:PT investigate test failure + // (I don't understand why its failing... All assertions are successful -- HELP!) + it.skip('should pass error along to the Form component and reset disabled back to `false`', async () => { + await render(); + const lastFormProps = getLastFormComponentProps(); + + expect(lastFormProps.error).toBeInstanceOf(Error); + expect(lastFormProps.disabled).toBe(false); + }); + }); + + describe('and a custom Submit handler is used', () => { + let handleSubmitCallback: jest.Mock; + let releaseSuccessSubmit: () => void; + let releaseFailureSubmit: () => void; + + beforeEach(async () => { + const deferred = getDeferred(); + releaseSuccessSubmit = () => act(() => deferred.resolve()); + releaseFailureSubmit = () => act(() => deferred.reject(new Error('oh oh. No good'))); + + handleSubmitCallback = jest.fn(async (item) => { + await deferred.promise; + + return new ExceptionsListItemGenerator().generateTrustedApp(item); + }); + + await render({ onFormSubmit: handleSubmitCallback }); + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + }); + + afterEach(() => { + if (releaseSuccessSubmit) { + releaseSuccessSubmit(); + } + }); + + it('should use custom submit handler when submit button is used', async () => { + expect(handleSubmitCallback).toHaveBeenCalled(); + + expect(renderResult.getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + it('should catch and show error if one is encountered', async () => { + releaseFailureSubmit(); + await waitFor(() => { + expect(renderResult.getByTestId('formError')).toBeTruthy(); + }); + }); + + it('should show a success toast', async () => { + releaseSuccessSubmit(); + + await waitFor(() => { + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + '"some name" has been added.' + ); + }); + + it('should clear the URL params', () => { + releaseSuccessSubmit(); + + expect(location.search).toBe(''); + }); + }); + }); + + describe('and in Edit mode', () => { + beforeEach(async () => { + history.push('somepage?show=edit&itemId=123'); + }); + + it('should show loader while initializing in edit mode', async () => { + const deferred = getDeferred(); + mockedApi.responseProvider.trustedApp.mockDelay.mockReturnValue(deferred.promise); + + const { getByTestId } = await render(); + + // The loader should be shown and the flyout footer should not be shown + expect(getByTestId('testPage-flyout-loader')).toBeTruthy(); + expect(() => getByTestId('testPage-flyout-cancelButton')).toThrow(); + expect(() => getByTestId('testPage-flyout-submitButton')).toThrow(); + + // The Form should not yet have been rendered + expect(FormComponentMock).not.toHaveBeenCalled(); + + act(() => deferred.resolve()); + + // we should call the GET API with the id provided + await waitFor(() => { + expect(mockedApi.responseProvider.trustedApp).toHaveBeenLastCalledWith( + expect.objectContaining({ + path: expect.any(String), + query: expect.objectContaining({ + item_id: '123', + }), + }) + ); + }); + }); + + it('should provide Form component with the item for edit', async () => { + const { getByTestId } = await render(); + + await act(async () => { + await waitFor(() => { + expect(getByTestId('formMock')).toBeTruthy(); + }); + }); + + expect(getLastFormComponentProps().item).toEqual({ + ...mockedApi.responseProvider.trustedApp({ + query: { item_id: '123' }, + } as unknown as HttpFetchOptionsWithPath), + created_at: expect.any(String), + }); + }); + + it('should show error toast and close flyout if item for edit does not exist', async () => { + mockedApi.responseProvider.trustedApp.mockImplementation(() => { + throw new Error('does not exist'); + }); + + await render(); + + await act(async () => { + await waitFor(() => { + expect(mockedApi.responseProvider.trustedApp).toHaveBeenCalled(); + }); + }); + + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledWith( + 'Failed to retrieve item for edit. Reason: does not exist' + ); + }); + + it('should not show the expired license callout', async () => { + const { queryByTestId, getByTestId } = await render(); + + await act(async () => { + await waitFor(() => { + expect(getByTestId('formMock')).toBeTruthy(); + }); + }); + + expect(queryByTestId('testPage-flyout-expiredLicenseCallout')).not.toBeTruthy(); + }); + + it('should show expired license warning when unsupported features are being used (downgrade scenario)', async () => { + // make the API return a policy specific item + const _generateResponse = mockedApi.responseProvider.trustedApp.getMockImplementation()!; + mockedApi.responseProvider.trustedApp.mockImplementation((params) => { + return { + ..._generateResponse(params), + tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${123}`], + }; + }); + + useUserPrivileges.mockReturnValue({ + ...useUserPrivileges(), + endpointPrivileges: getEndpointPrivilegesInitialStateMock({ + canCreateArtifactsByPolicy: false, + }), + }); + + const { getByTestId } = await render(); + + await act(async () => { + await waitFor(() => { + expect(getByTestId('formMock')).toBeTruthy(); + }); + }); + + expect(getByTestId('testPage-flyout-expiredLicenseCallout')).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts new file mode 100644 index 00000000000000..ecfae10e1842a3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/no_data_empty_state.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + trustedAppsAllHttpMocks, + TrustedAppsGetListHttpMocksInterface, +} from '../../../pages/mocks'; +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ArtifactListPageProps } from '../artifact_list_page'; +import { AppContextTestRender } from '../../../../common/mock/endpoint'; +import { getArtifactListPageRenderingSetup, getFormComponentMock } from '../mocks'; + +describe('When showing the Empty State in ArtifactListPage', () => { + let render: ( + props?: Partial + ) => ReturnType; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let mockedApi: ReturnType; + let getLastFormComponentProps: ReturnType< + typeof getFormComponentMock + >['getLastFormComponentProps']; + let originalListApiResponseProvider: TrustedAppsGetListHttpMocksInterface['trustedAppsList']; + + beforeEach(() => { + const renderSetup = getArtifactListPageRenderingSetup(); + + ({ history, mockedApi, getLastFormComponentProps } = renderSetup); + + originalListApiResponseProvider = + mockedApi.responseProvider.trustedAppsList.getMockImplementation()!; + + render = (props = {}) => { + mockedApi.responseProvider.trustedAppsList.mockReturnValue({ + data: [], + page: 1, + per_page: 10, + total: 0, + }); + + renderResult = renderSetup.renderArtifactListPage(props); + return renderResult; + }; + }); + + it('should display empty state', async () => { + render(); + + await waitFor(async () => { + expect(renderResult.getByTestId('testPage-emptyState')); + }); + }); + + it('should hide page headers', async () => { + render(); + + expect(renderResult.queryByTestId('header-page-title')).toBe(null); + }); + + it('should open create flyout when primary button is clicked', async () => { + render(); + const addButton = await renderResult.findByTestId('testPage-emptyState-addButton'); + + act(() => { + userEvent.click(addButton); + }); + + expect(renderResult.getByTestId('testPage-flyout')).toBeTruthy(); + expect(history.location.search).toMatch(/show=create/); + }); + + describe('and the first item is created', () => { + it('should show the list after creating first item and remove empty state', async () => { + render(); + const addButton = await renderResult.findByTestId('testPage-emptyState-addButton'); + + act(() => { + userEvent.click(addButton); + }); + + await waitFor(async () => { + expect(renderResult.getByTestId('testPage-flyout')); + }); + + // indicate form is valid + act(() => { + const lastProps = getLastFormComponentProps(); + lastProps.onChange({ item: { ...lastProps.item, name: 'some name' }, isValid: true }); + }); + + mockedApi.responseProvider.trustedAppsList.mockImplementation( + originalListApiResponseProvider + ); + + // Submit form + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + + // wait for the list to show up + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId('testPage-list')).toBeTruthy(); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx new file mode 100644 index 00000000000000..3cf1874f8d5517 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { act, waitFor, within } from '@testing-library/react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import userEvent from '@testing-library/user-event'; +import { ArtifactFormComponentProps } from './types'; +import { ArtifactListPage, ArtifactListPageProps } from './artifact_list_page'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { trustedAppsAllHttpMocks } from '../../pages/mocks'; +import { TrustedAppsApiClient } from '../../pages/trusted_apps/service/trusted_apps_api_client'; +import { artifactListPageLabels } from './translations'; + +export const getFormComponentMock = (): { + FormComponentMock: jest.Mock>; + /** + * Returns the props object that the Form component was last called with + */ + getLastFormComponentProps: () => ArtifactFormComponentProps; +} => { + const FormComponentMock = jest.fn((({ mode, error, disabled }: ArtifactFormComponentProps) => { + return ( +
+
{`${mode} form`}
+
{`Is Disabled: ${disabled}`}
+ {error && ( + <> +
{error.message}
+
{JSON.stringify(error.body)}
+ + )} +
+ ); + }) as unknown as jest.Mock>); + + const getLastFormComponentProps = (): ArtifactFormComponentProps => { + return FormComponentMock.mock.calls[FormComponentMock.mock.calls.length - 1][0]; + }; + + return { + FormComponentMock, + getLastFormComponentProps, + }; +}; + +interface DeferredInterface { + promise: Promise; + resolve: (data: T) => void; + reject: (e: Error) => void; +} + +export const getDeferred = function (): DeferredInterface { + let resolve: DeferredInterface['resolve']; + let reject: DeferredInterface['reject']; + + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + // @ts-ignore + return { promise, resolve, reject }; +}; + +export interface ArtifactListPageRenderingSetup { + renderArtifactListPage: ( + props?: Partial + ) => ReturnType; + history: AppContextTestRender['history']; + coreStart: AppContextTestRender['coreStart']; + mockedApi: ReturnType; + FormComponentMock: ReturnType['FormComponentMock']; + getLastFormComponentProps: ReturnType['getLastFormComponentProps']; + getFirstCard(props?: Partial<{ showActions: boolean }>): Promise; +} + +/** + * Returns the setup needed to render the ArtifactListPage for unit tests + */ +export const getArtifactListPageRenderingSetup = (): ArtifactListPageRenderingSetup => { + const mockedContext = createAppRootMockRenderer(); + + const { history, coreStart } = mockedContext; + const mockedApi = trustedAppsAllHttpMocks(coreStart.http); + + const apiClient = new TrustedAppsApiClient(coreStart.http); + const labels = { ...artifactListPageLabels }; + + const { FormComponentMock, getLastFormComponentProps } = getFormComponentMock(); + + let renderResult: ReturnType; + + const renderArtifactListPage = (props: Partial = {}) => { + renderResult = mockedContext.render( + + ); + + return renderResult; + }; + + const getFirstCard = async ({ + showActions = false, + }: Partial<{ showActions: boolean }> = {}): Promise => { + const cards = await renderResult.findAllByTestId('testPage-card'); + + if (cards.length === 0) { + throw new Error('No cards found!'); + } + + const card = cards[0]; + + if (showActions) { + await act(async () => { + userEvent.click(within(card).getByTestId('testPage-card-header-actions-button')); + + await waitFor(() => { + expect(renderResult.getByTestId('testPage-card-header-actions-contextMenuPanel')); + }); + }); + } + + return card; + }; + + return { + renderArtifactListPage, + history, + coreStart, + mockedApi, + FormComponentMock, + getLastFormComponentProps, + getFirstCard, + }; +}; From 02374bdd317f9353a9472cd502075cf8ba0ff6d4 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 16 Mar 2022 02:07:10 +0200 Subject: [PATCH 13/39] [Cases] Fix alerts count on all cases table (#127721) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/services/attachments/index.ts | 40 ++++-------- .../tests/common/cases/find_cases.ts | 65 ++++++++++++++++++- 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/cases/server/services/attachments/index.ts b/x-pack/plugins/cases/server/services/attachments/index.ts index 5b3ee796faf96e..1b52a6c2bf1532 100644 --- a/x-pack/plugins/cases/server/services/attachments/index.ts +++ b/x-pack/plugins/cases/server/services/attachments/index.ts @@ -304,15 +304,11 @@ export class AttachmentService { key: string; doc_count: number; reverse: { + alerts: { + value: number; + }; comments: { - buckets: { - alerts: { - doc_count: number; - }; - nonAlerts: { - doc_count: number; - }; - }; + doc_count: number; }; }; }>; @@ -331,8 +327,8 @@ export class AttachmentService { return ( res.aggregations?.references.caseIds.buckets.reduce((acc, idBucket) => { acc.set(idBucket.key, { - nonAlerts: idBucket.reverse.comments.buckets.nonAlerts.doc_count, - alerts: idBucket.reverse.comments.buckets.alerts.doc_count, + nonAlerts: idBucket.reverse.comments.doc_count, + alerts: idBucket.reverse.alerts.value, }); return acc; }, new Map()) ?? new Map() @@ -357,23 +353,15 @@ export class AttachmentService { reverse: { reverse_nested: {}, aggregations: { + alerts: { + cardinality: { + field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, + }, + }, comments: { - filters: { - filters: { - alerts: { - term: { - [`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`]: CommentType.alert, - }, - }, - nonAlerts: { - bool: { - must_not: { - term: { - [`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`]: CommentType.alert, - }, - }, - }, - }, + filter: { + term: { + [`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`]: CommentType.user, }, }, }, diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts index 155eb912af489a..48d6515d73d0db 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts @@ -23,7 +23,11 @@ import { updateCase, createComment, } from '../../../../common/lib/utils'; -import { CaseResponse, CaseStatuses } from '../../../../../../plugins/cases/common/api'; +import { + CaseResponse, + CaseStatuses, + CommentType, +} from '../../../../../../plugins/cases/common/api'; import { obsOnly, secOnly, @@ -47,6 +51,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); describe('find_cases', () => { describe('basic tests', () => { @@ -195,6 +200,64 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + describe('alerts', () => { + const defaultSignalsIndex = '.siem-signals-default-000001'; + const signalID = '4679431ee0ba3209b6fcd60a255a696886fe0a7d18f5375de510ff5b68fa6b78'; + const signalID2 = '1023bcfea939643c5e51fd8df53797e0ea693cee547db579ab56d96402365c1e'; + + beforeEach(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/cases/signals/default'); + }); + + afterEach(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/cases/signals/default'); + await deleteAllCaseItems(es); + }); + + it('correctly counts alerts ignoring duplicates', async () => { + const postedCase = await createCase(supertest, postCaseReq); + /** + * Adds three comments of type alerts. + * The first two have the same alertId. + * The third has different alertId. + */ + for (const alertId of [signalID, signalID, signalID2]) { + await createComment({ + supertest, + caseId: postedCase.id, + params: { + alertId, + index: defaultSignalsIndex, + rule: { id: 'test-rule-id', name: 'test-index-id' }, + type: CommentType.alert, + owner: 'securitySolutionFixture', + }, + }); + } + + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + + const cases = await findCases({ supertest }); + expect(cases).to.eql({ + ...findCasesResp, + total: 1, + cases: [ + { + ...patchedCase, + comments: [], + totalAlerts: 2, + totalComment: 1, + }, + ], + count_open_cases: 1, + }); + }); + }); + describe('find_cases pagination', () => { const numCases = 10; before(async () => { From 8882f8b92d1a2e49bba6089451ee904a3a06473f Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 16 Mar 2022 02:24:16 +0200 Subject: [PATCH 14/39] [Cases] Hide alerts column if alerts are not supported (#127585) --- x-pack/plugins/cases/common/constants.ts | 6 +- x-pack/plugins/cases/common/ui/types.ts | 6 +- .../all_cases/all_cases_list.test.tsx | 97 ++++++++++++++----- .../public/components/all_cases/columns.tsx | 25 +++-- ..._cases_add_to_existing_case_modal.test.tsx | 2 +- .../cases/public/components/app/index.tsx | 2 +- .../components/case_action_bar/index.test.tsx | 2 +- .../public/components/cases_context/index.tsx | 10 +- .../cases_context/use_cases_features.test.tsx | 60 ++++++++++++ .../cases_context/use_cases_features.tsx | 16 ++- .../use_cases_add_to_new_case_flyout.test.tsx | 2 +- .../components/create/form_context.test.tsx | 2 +- 12 files changed, 182 insertions(+), 48 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/cases_context/use_cases_features.test.tsx diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 0f3fd6345a672f..660ee42c6cc91e 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ import { ConnectorTypes } from './api'; -import { CasesContextFeatures } from './ui/types'; +import { CasesFeaturesAllRequired } from './ui/types'; export const DEFAULT_DATE_FORMAT = 'dateFormat' as const; export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz' as const; @@ -117,7 +117,7 @@ export const MAX_TITLE_LENGTH = 64 as const; * Cases features */ -export const DEFAULT_FEATURES: CasesContextFeatures = Object.freeze({ - alerts: { sync: true }, +export const DEFAULT_FEATURES: CasesFeaturesAllRequired = Object.freeze({ + alerts: { sync: true, enabled: true }, metrics: [], }); diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 8abc0805b8a3fc..3b32475f33dde8 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -19,11 +19,15 @@ import { } from '../api'; import { SnakeToCamelCase } from '../types'; +type DeepRequired = { [K in keyof T]: DeepRequired } & Required; + export interface CasesContextFeatures { - alerts: { sync: boolean }; + alerts: { sync?: boolean; enabled?: boolean }; metrics: CaseMetricsFeature[]; } +export type CasesFeaturesAllRequired = DeepRequired; + export type CasesFeatures = Partial; export interface CasesUiConfigType { diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 8865d67703121a..43a36188fcf523 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -34,6 +34,8 @@ import { registerConnectorsToMockActionRegistry } from '../../common/mock/regist import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock'; import { waitForComponentToUpdate } from '../../common/test_utils'; import { usePostComment } from '../../containers/use_post_comment'; +import { useGetTags } from '../../containers/use_get_tags'; +import { useGetReporters } from '../../containers/use_get_reporters'; jest.mock('../../containers/use_post_comment'); jest.mock('../../containers/use_bulk_update_case'); @@ -41,6 +43,8 @@ jest.mock('../../containers/use_delete_cases'); jest.mock('../../containers/use_get_cases'); jest.mock('../../containers/use_get_cases_status'); jest.mock('../../containers/use_get_action_license'); +jest.mock('../../containers/use_get_tags'); +jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../common/lib/kibana'); jest.mock('../../common/navigation/hooks'); @@ -53,6 +57,8 @@ const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; const useUpdateCasesMock = useUpdateCases as jest.Mock; const useGetActionLicenseMock = useGetActionLicense as jest.Mock; +const useGetTagsMock = useGetTags as jest.Mock; +const useGetReportersMock = useGetReporters as jest.Mock; const useKibanaMock = useKibana as jest.MockedFunction; const useConnectorsMock = useConnectors as jest.Mock; const usePostCommentMock = usePostComment as jest.Mock; @@ -149,6 +155,14 @@ describe('AllCasesListGeneric', () => { useDeleteCasesMock.mockReturnValue(defaultDeleteCases); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); useGetActionLicenseMock.mockReturnValue(defaultActionLicense); + useGetTagsMock.mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags: jest.fn() }); + useGetReportersMock.mockReturnValue({ + reporters: ['casetester'], + respReporters: [{ username: 'casetester' }], + isLoading: true, + isError: false, + fetchReporters: jest.fn(), + }); useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); mockKibana(); @@ -214,19 +228,24 @@ describe('AllCasesListGeneric', () => { ], }, }); + const wrapper = mount( ); + const checkIt = (columnName: string, key: number) => { const column = wrapper.find('[data-test-subj="cases-table"] tbody .euiTableRowCell').at(key); expect(column.find('.euiTableRowCell--hideForDesktop').text()).toEqual(columnName); expect(column.find('span').text()).toEqual(emptyTag); }; - const { result } = renderHook(() => - useCasesColumns(defaultColumnArgs) + const { result } = renderHook( + () => useCasesColumns(defaultColumnArgs), + { + wrapper: ({ children }) => {children}, + } ); await waitFor(() => { @@ -696,11 +715,15 @@ describe('AllCasesListGeneric', () => { ); - const { result } = renderHook(() => - useCasesColumns({ - ...defaultColumnArgs, - isSelectorView: true, - }) + const { result } = renderHook( + () => + useCasesColumns({ + ...defaultColumnArgs, + isSelectorView: true, + }), + { + wrapper: ({ children }) => {children}, + } ); expect(result.current.find((i) => i.name === 'Status')).toBeFalsy(); @@ -780,7 +803,8 @@ describe('AllCasesListGeneric', () => { ); - userEvent.click(screen.getByTestId('checkboxSelectAll')); + const allCheckbox = await screen.findByTestId('checkboxSelectAll'); + userEvent.click(allCheckbox); const checkboxes = await screen.findAllByRole('checkbox'); for (const checkbox of checkboxes) { @@ -792,7 +816,7 @@ describe('AllCasesListGeneric', () => { expect(checkbox).not.toBeChecked(); } - waitForComponentToUpdate(); + await waitForComponentToUpdate(); }); it('should deselect cases when changing filters', async () => { @@ -801,26 +825,15 @@ describe('AllCasesListGeneric', () => { selectedCases: [], }); - const { rerender } = render( + render( ); - /** Something really weird is going on and we have to rerender - * to get the correct html output. Not sure why. - * - * If you run the test alone the rerender is not needed. - * If you run the test along with the above test - * then you need the rerender - */ - rerender( - - - - ); + const allCheckbox = await screen.findByTestId('checkboxSelectAll'); - userEvent.click(screen.getByTestId('checkboxSelectAll')); + userEvent.click(allCheckbox); const checkboxes = await screen.findAllByRole('checkbox'); for (const checkbox of checkboxes) { @@ -834,6 +847,42 @@ describe('AllCasesListGeneric', () => { expect(checkbox).not.toBeChecked(); } - waitForComponentToUpdate(); + await waitForComponentToUpdate(); + }); + + it('should hide the alerts column if the alert feature is disabled', async () => { + expect.assertions(1); + + const { findAllByTestId } = render( + + + + ); + + await expect(findAllByTestId('case-table-column-alertsCount')).rejects.toThrow(); + }); + + it('should show the alerts column if the alert feature is enabled', async () => { + const { findAllByTestId } = render( + + + + ); + + const alertCounts = await findAllByTestId('case-table-column-alertsCount'); + + expect(alertCounts.length).toBeGreaterThan(0); + }); + + it('should show the alerts column if the alert object is empty', async () => { + const { findAllByTestId } = render( + + + + ); + + const alertCounts = await findAllByTestId('case-table-column-alertsCount'); + + expect(alertCounts.length).toBeGreaterThan(0); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index c1d78787a5422f..391bef00b6e868 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -41,6 +41,7 @@ import { getConnectorIcon } from '../utils'; import { PostComment } from '../../containers/use_post_comment'; import { CaseAttachments } from '../../types'; import type { CasesOwners } from '../../client/helpers/can_use_cases'; +import { useCasesFeatures } from '../cases_context/use_cases_features'; export type CasesColumns = | EuiTableActionsColumnType @@ -103,6 +104,8 @@ export const useCasesColumns = ({ isLoading: isDeleting, } = useDeleteCases(); + const { isAlertsEnabled } = useCasesFeatures(); + const [deleteThisCase, setDeleteThisCase] = useState({ id: '', title: '', @@ -239,15 +242,19 @@ export const useCasesColumns = ({ }, truncateText: true, }, - { - align: RIGHT_ALIGNMENT, - field: 'totalAlerts', - name: ALERTS, - render: (totalAlerts: Case['totalAlerts']) => - totalAlerts != null - ? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`) - : getEmptyTagValue(), - }, + ...(isAlertsEnabled + ? [ + { + align: RIGHT_ALIGNMENT, + field: 'totalAlerts', + name: ALERTS, + render: (totalAlerts: Case['totalAlerts']) => + totalAlerts != null + ? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`) + : getEmptyTagValue(), + }, + ] + : []), ...(showSolutionColumn ? [ { diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index 6a224949db8be2..df40ccd3b1e90b 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -32,7 +32,7 @@ describe('use cases add to existing case modal hook', () => { appTitle: 'jest', basePath: '/jest', dispatch, - features: { alerts: { sync: true }, metrics: [] }, + features: { alerts: { sync: true, enabled: true }, metrics: [] }, releasePhase: 'ga', }} > diff --git a/x-pack/plugins/cases/public/components/app/index.tsx b/x-pack/plugins/cases/public/components/app/index.tsx index 2fcd10fb14312a..5fb46676d92316 100644 --- a/x-pack/plugins/cases/public/components/app/index.tsx +++ b/x-pack/plugins/cases/public/components/app/index.tsx @@ -25,7 +25,7 @@ const CasesAppComponent: React.FC = () => { useFetchAlertData: () => [false, {}], userCanCrud: userCapabilities.crud, basePath: '/', - features: { alerts: { sync: false } }, + features: { alerts: { enabled: false } }, releasePhase: 'experimental', })} diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx index cb14d734bed7a0..5d5f18845d09ad 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx @@ -119,7 +119,7 @@ describe('CaseActionBar', () => { it('should not show the sync alerts toggle when alerting is disabled', () => { const { queryByText } = render( - + ); diff --git a/x-pack/plugins/cases/public/components/cases_context/index.tsx b/x-pack/plugins/cases/public/components/cases_context/index.tsx index 70490882d31b3e..412ab03b3eceba 100644 --- a/x-pack/plugins/cases/public/components/cases_context/index.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/index.tsx @@ -15,7 +15,7 @@ import { casesContextReducer, getInitialCasesContextState, } from './cases_context_reducer'; -import { CasesContextFeatures, CasesFeatures } from '../../containers/types'; +import { CasesFeaturesAllRequired, CasesFeatures } from '../../containers/types'; import { CasesGlobalComponents } from './cases_global_components'; import { ReleasePhase } from '../types'; @@ -27,7 +27,7 @@ export interface CasesContextValue { appTitle: string; userCanCrud: boolean; basePath: string; - features: CasesContextFeatures; + features: CasesFeaturesAllRequired; releasePhase: ReleasePhase; dispatch: CasesContextValueDispatch; } @@ -59,7 +59,11 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ * The empty object at the beginning avoids the mutation * of the DEFAULT_FEATURES object */ - features: merge({}, DEFAULT_FEATURES, features), + features: merge( + {}, + DEFAULT_FEATURES, + features + ), releasePhase, dispatch, })); diff --git a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.test.tsx b/x-pack/plugins/cases/public/components/cases_context/use_cases_features.test.tsx new file mode 100644 index 00000000000000..22c39b525107d2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/cases_context/use_cases_features.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useCasesFeatures, UseCasesFeatures } from './use_cases_features'; +import { TestProviders } from '../../common/mock'; +import { CasesContextFeatures } from '../../containers/types'; + +describe('useCasesFeatures', () => { + // isAlertsEnabled, isSyncAlertsEnabled, alerts + const tests: Array<[boolean, boolean, CasesContextFeatures['alerts']]> = [ + [true, true, { enabled: true, sync: true }], + [true, false, { enabled: true, sync: false }], + [false, false, { enabled: false, sync: true }], + [false, false, { enabled: false, sync: false }], + [false, false, { enabled: false }], + // the default for sync is true + [true, true, { enabled: true }], + // the default for enabled is true + [true, true, { sync: true }], + // the default for enabled is true + [true, false, { sync: false }], + // the default for enabled and sync is true + [true, true, {}], + ]; + + it.each(tests)( + 'returns isAlertsEnabled=%s and isSyncAlertsEnabled=%s if feature.alerts=%s', + async (isAlertsEnabled, isSyncAlertsEnabled, alerts) => { + const { result } = renderHook<{}, UseCasesFeatures>(() => useCasesFeatures(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toEqual({ + isAlertsEnabled, + isSyncAlertsEnabled, + metricsFeatures: [], + }); + } + ); + + it('returns the metrics correctly', async () => { + const { result } = renderHook<{}, UseCasesFeatures>(() => useCasesFeatures(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toEqual({ + isAlertsEnabled: true, + isSyncAlertsEnabled: true, + metricsFeatures: ['connectors'], + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx b/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx index 1e801edaa3b1bc..6241e81e419b93 100644 --- a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx @@ -9,7 +9,8 @@ import { useMemo } from 'react'; import { CaseMetricsFeature } from '../../containers/types'; import { useCasesContext } from './use_cases_context'; -interface UseCasesFeatures { +export interface UseCasesFeatures { + isAlertsEnabled: boolean; isSyncAlertsEnabled: boolean; metricsFeatures: CaseMetricsFeature[]; } @@ -18,10 +19,19 @@ export const useCasesFeatures = (): UseCasesFeatures => { const { features } = useCasesContext(); const casesFeatures = useMemo( () => ({ - isSyncAlertsEnabled: features.alerts.sync, + isAlertsEnabled: features.alerts.enabled, + /** + * If the alerts feature is disabled we will disable everything. + * If not, then we honor the sync option. + * The sync and enabled option in DEFAULT_FEATURES in x-pack/plugins/cases/common/constants.ts + * is defaulted to true. This will help consumers to set the enabled + * option to true and get the whole alerts experience without the need + * to explicitly set the sync to true + */ + isSyncAlertsEnabled: !features.alerts.enabled ? false : features.alerts.sync, metricsFeatures: features.metrics, }), - [features] + [features.alerts.enabled, features.alerts.sync, features.metrics] ); return casesFeatures; }; diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx index e569b1ee799526..444ba72d92bdf7 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx @@ -29,7 +29,7 @@ describe('use cases add to new case flyout hook', () => { appTitle: 'jest', basePath: '/jest', dispatch, - features: { alerts: { sync: true }, metrics: [] }, + features: { alerts: { sync: true, enabled: true }, metrics: [] }, releasePhase: 'ga', }} > diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index a6f176a4c929ff..f70ea7eba48961 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -268,7 +268,7 @@ describe('Create case', () => { }); const wrapper = mount( - + From a4ea4a8a0a662513e9ab5e5019592fd4f8bc2f92 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Wed, 16 Mar 2022 00:28:21 -0400 Subject: [PATCH 15/39] [Security Solution][Endpoint][Policy] Endpoint Count column in Policy List (#126744) --- .../components/policy_endpoint_list_link.tsx | 65 +++++++++++++++++++ .../pages/endpoint_hosts/view/index.tsx | 46 ++++++++++++- .../pages/policy/view/policy_list.test.tsx | 57 +++++++++++++++- .../pages/policy/view/policy_list.tsx | 39 ++++++++++- .../management/services/policies/hooks.ts | 42 +++++++++++- 5 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/components/policy_endpoint_list_link.tsx diff --git a/x-pack/plugins/security_solution/public/management/components/policy_endpoint_list_link.tsx b/x-pack/plugins/security_solution/public/management/components/policy_endpoint_list_link.tsx new file mode 100644 index 00000000000000..1386aff8bc840a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/policy_endpoint_list_link.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiLink, EuiLinkAnchorProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useLocation } from 'react-router-dom'; +import { useNavigateByRouterEventHandler } from '../../common/hooks/endpoint/use_navigate_by_router_event_handler'; +import { useAppUrl } from '../../common/lib/kibana/hooks'; +import { getEndpointListPath, getPoliciesPath } from '../common/routing'; +import { APP_UI_ID } from '../../../common/constants'; + +/** + * Returns a link component that navigates to the endpoint list page filtered by a specific policy + */ +export const PolicyEndpointListLink = memo< + Omit & { + policyId: string; + } +>(({ policyId, children, ...otherProps }) => { + const filterByPolicyQuery = `(language:kuery,query:'united.endpoint.Endpoint.policy.applied.id : "${policyId}"')`; + const { search } = useLocation(); + const { getAppUrl } = useAppUrl(); + const { toRoutePathWithBackOptions, toRouteUrl } = useMemo(() => { + const endpointListPath = getEndpointListPath({ + name: 'endpointList', + admin_query: filterByPolicyQuery, + }); + const policyListPath = getPoliciesPath(search); + const backLink = { + navigateTo: [ + APP_UI_ID, + { + path: policyListPath, + }, + ], + label: i18n.translate('xpack.securitySolution.policy.backToPolicyList', { + defaultMessage: 'Back to policy list', + }), + href: getAppUrl({ path: policyListPath }), + }; + return { + toRoutePathWithBackOptions: { + pathname: getEndpointListPath({ name: 'endpointList' }), + search: `?admin_query=${filterByPolicyQuery}`, + state: { backLink }, + }, + toRouteUrl: getAppUrl({ path: endpointListPath }), + }; + }, [getAppUrl, filterByPolicyQuery, search]); + const clickHandler = useNavigateByRouterEventHandler(toRoutePathWithBackOptions); + + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {children} + + ); +}); + +PolicyEndpointListLink.displayName = 'PolicyEndpointListLink'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 46b9fef4df3578..4a570037f9159b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -22,7 +22,7 @@ import { EuiFlexItem, EuiCallOut, } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { createStructuredSelector } from 'reselect'; @@ -34,7 +34,7 @@ import { isPolicyOutOfDate } from '../utils'; import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants'; import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { CreateStructuredSelector } from '../../../../common/store'; -import { Immutable, HostInfo } from '../../../../../common/endpoint/types'; +import { Immutable, HostInfo, PolicyDetailsRouteState } from '../../../../../common/endpoint/types'; import { DEFAULT_POLL_INTERVAL, MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants'; import { PolicyEmptyState, HostsEmptyState } from '../../../components/management_empty_state'; import { FormattedDate } from '../../../../common/components/formatted_date'; @@ -46,7 +46,11 @@ import { pagePathGetters, } from '../../../../../../fleet/public'; import { SecurityPageName } from '../../../../app/types'; -import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/routing'; +import { + getEndpointListPath, + getEndpointDetailsPath, + getPoliciesPath, +} from '../../../common/routing'; import { useFormatUrl } from '../../../../common/components/link_to'; import { useAppUrl } from '../../../../common/lib/kibana/hooks'; import { EndpointAction } from '../store/action'; @@ -60,6 +64,10 @@ import { EndpointAgentStatus } from './components/endpoint_agent_status'; import { CallOut } from '../../../../common/components/callouts'; import { metadataTransformPrefix } from '../../../../../common/endpoint/constants'; import { WARNING_TRANSFORM_STATES, APP_UI_ID } from '../../../../../common/constants'; +import { + BackToExternalAppButton, + BackToExternalAppButtonProps, +} from '../../../components/back_to_external_app_button/back_to_external_app_button'; const MAX_PAGINATED_ITEM = 9999; const TRANSFORM_URL = '/data/transform'; @@ -130,6 +138,37 @@ export const EndpointList = () => { const [showTransformFailedCallout, setShowTransformFailedCallout] = useState(false); const [shouldCheckTransforms, setShouldCheckTransforms] = useState(true); + const { state: routeState = {} } = useLocation(); + + const backLinkOptions = useMemo(() => { + if (routeState?.backLink) { + return { + onBackButtonNavigateTo: routeState.backLink.navigateTo, + backButtonLabel: routeState.backLink.label, + backButtonUrl: routeState.backLink.href, + }; + } + + const policyListPath = getPoliciesPath(); + + return { + backButtonLabel: i18n.translate('xpack.securitySolution.endpoint.list.backToPolicyButton', { + defaultMessage: 'Back to policy list', + }), + backButtonUrl: getAppUrl({ path: policyListPath }), + onBackButtonNavigateTo: [ + APP_UI_ID, + { + path: policyListPath, + }, + ], + }; + }, [getAppUrl, routeState?.backLink]); + + const backToPolicyList = ( + + ); + useEffect(() => { // if no endpoint policy, skip transform check if (!shouldCheckTransforms || !policyItems || !policyItems.length) { @@ -623,6 +662,7 @@ export const EndpointList = () => { defaultMessage="Hosts running endpoint security" /> } + headerBackComponent={routeState.backLink && backToPolicyList} > {hasSelectedEndpoint && } <> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx index 8c5b355ea05f1a..025c36261d1200 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx @@ -6,16 +6,23 @@ */ import React from 'react'; -import { act, waitFor } from '@testing-library/react'; +import { act, waitFor, fireEvent } from '@testing-library/react'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies/policies'; import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../services/policies/test_mock_utilts'; import { PolicyList } from './policy_list'; +import { sendGetAgentPolicyList } from '../store/services/ingest'; +import { GetPolicyListResponse } from '../types'; +import { getEndpointListPath, getPoliciesPath } from '../../../common/routing'; +import { APP_UI_ID } from '../../../../../common/constants'; jest.mock('../../../services/policies/policies'); +jest.mock('../store/services/ingest'); const getPackagePolicies = sendGetEndpointSpecificPackagePolicies as jest.Mock; +const getAgentPolicies = sendGetAgentPolicyList as jest.Mock; + describe('When on the policy list page', () => { let render: () => ReturnType; let renderResult: ReturnType; @@ -29,15 +36,29 @@ describe('When on the policy list page', () => { }); afterEach(() => { - getPackagePolicies.mockReset(); + jest.clearAllMocks(); }); describe('and data exists', () => { + let policies: GetPolicyListResponse; beforeEach(async () => { - getPackagePolicies.mockImplementation(() => sendGetEndpointSpecificPackagePoliciesMock()); + policies = await sendGetEndpointSpecificPackagePoliciesMock(); + getPackagePolicies.mockImplementation(async () => { + return policies; + }); + getAgentPolicies.mockResolvedValue({ + items: [ + { package_policies: [policies.items[0].id], agents: 4 }, + { package_policies: [policies.items[1].id], agents: 2 }, + { package_policies: [policies.items[2].id], agents: 5 }, + { package_policies: [policies.items[3].id], agents: 1 }, + { package_policies: [policies.items[4].id], agents: 3 }, + ], + }); render(); await waitFor(() => { expect(sendGetEndpointSpecificPackagePolicies).toHaveBeenCalled(); + expect(sendGetAgentPolicyList).toHaveBeenCalled(); }); }); it('should display the policy list table', () => { @@ -58,6 +79,36 @@ describe('When on the policy list page', () => { expect(updatedByCells).toBeTruthy(); expect(updatedByCells.length).toBe(5); }); + it('should show the correct endpoint count', () => { + const endpointCount = renderResult.getAllByTestId('policyEndpointCountLink'); + expect(endpointCount[0].textContent).toBe('4'); + }); + it('endpoint count link should navigate to the endpoint list filtered by policy', () => { + const policyId = policies.items[0].id; + const filterByPolicyQuery = `?admin_query=(language:kuery,query:'united.endpoint.Endpoint.policy.applied.id : "${policyId}"')`; + const backLink = { + backLink: { + navigateTo: [ + APP_UI_ID, + { + path: getPoliciesPath(), + }, + ], + label: 'Back to policy list', + href: '/app/security/administration/policy', + }, + }; + const endpointCount = renderResult.getAllByTestId('policyEndpointCountLink')[0]; + fireEvent.click(endpointCount); + + expect(history.location.pathname).toEqual(getEndpointListPath({ name: 'endpointList' })); + expect(history.location.search).toEqual(filterByPolicyQuery); + expect(history.location.state).toEqual(backLink); + + // reset test to the policy page + history.push('/administration/policies'); + render(); + }); }); describe('pagination', () => { beforeEach(async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 36c43de407104d..e6da255c1455ee 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -23,7 +23,12 @@ import { FormattedDate } from '../../../../common/components/formatted_date'; import { EndpointPolicyLink } from '../../../components/endpoint_policy_link'; import { PolicyData } from '../../../../../common/endpoint/types'; import { useUrlPagination } from '../../../components/hooks/use_url_pagination'; -import { useGetEndpointSpecificPolicies } from '../../../services/policies/hooks'; +import { + useGetAgentCountForPolicy, + useGetEndpointSpecificPolicies, +} from '../../../services/policies/hooks'; +import { AgentPolicy } from '../../../../../../fleet/common'; +import { PolicyEndpointListLink } from '../../../components/policy_endpoint_list_link'; export const PolicyList = memo(() => { const { pagination, pageSizeOptions, setPagination } = useUrlPagination(); @@ -34,6 +39,21 @@ export const PolicyList = memo(() => { perPage: pagination.pageSize, }); + // endpoint count per policy + const policyIds = data?.items.map((policies) => policies.id) ?? []; + const { data: endpointCount = { items: [] } } = useGetAgentCountForPolicy({ + policyIds, + customQueryOptions: { enabled: policyIds.length > 0 }, + }); + + const policyIdToEndpointCount = useMemo(() => { + const map = new Map(); + for (const policy of endpointCount?.items) { + map.set(policy.package_policies[0], policy.agents ?? 0); + } + return map; + }, [endpointCount]); + const totalItemCount = data?.total ?? 0; const policyColumns = useMemo(() => { @@ -131,10 +151,23 @@ export const PolicyList = memo(() => { }, }, { - field: '-', + field: '', name: i18n.translate('xpack.securitySolution.policy.list.endpoints', { defaultMessage: 'Endpoints', }), + dataType: 'number', + width: '8%', + render: (policy: PolicyData) => { + return ( + + {policyIdToEndpointCount.get(policy.id)} + + ); + }, }, { field: '-', @@ -143,7 +176,7 @@ export const PolicyList = memo(() => { }), }, ]; - }, []); + }, [policyIdToEndpointCount]); const handleTableOnChange = useCallback( ({ page }: CriteriaWithPagination) => { diff --git a/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts b/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts index 3f3f4b1574c21c..a3be84bcf1eeab 100644 --- a/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts @@ -4,12 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { QueryObserverResult, useQuery } from 'react-query'; +import { QueryObserverResult, useQuery, UseQueryOptions } from 'react-query'; +import { HttpFetchError } from 'kibana/public'; +import { + AGENT_POLICY_SAVED_OBJECT_TYPE, + GetAgentPoliciesResponse, +} from '../../../../../fleet/common'; import { useHttp } from '../../../common/lib/kibana/hooks'; -import { ServerApiError } from '../../../common/types'; import { MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../common/constants'; +import { sendGetAgentPolicyList } from '../../pages/policy/store/services/ingest'; import { GetPolicyListResponse } from '../../pages/policy/types'; import { sendGetEndpointSpecificPackagePolicies } from './policies'; +import { ServerApiError } from '../../../common/types'; export function useGetEndpointSpecificPolicies( { @@ -40,3 +46,35 @@ export function useGetEndpointSpecificPolicies( } ); } + +/** + * @param policyIds: list of policyIds to grab the agent policies list for + * @param customQueryOptions: useQuery options such as enabled, which will set whether the query automatically runs or not + * + * This hook returns the fleet agent policies list filtered by policy id + */ +export function useGetAgentCountForPolicy({ + policyIds, + customQueryOptions = {}, +}: { + policyIds: string[]; + customQueryOptions?: UseQueryOptions; +}): QueryObserverResult { + const http = useHttp(); + return useQuery( + ['endpointCountForPolicy', policyIds], + () => { + return sendGetAgentPolicyList(http, { + query: { + perPage: 50, + kuery: `${AGENT_POLICY_SAVED_OBJECT_TYPE}.package_policies: (${policyIds.join(' or ')})`, + }, + }); + }, + { + refetchIntervalInBackground: false, + refetchOnWindowFocus: false, + ...customQueryOptions, + } + ); +} From deb7099b28b42c52c985000bf012b00040b68d5c Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Wed, 16 Mar 2022 12:31:48 +0500 Subject: [PATCH 16/39] [Discover] Fix csv export with relative time filter from discover main view (#123206) * [Discover] fix relative time filter for csv export from discover main page * [Discover] fix array assignment * [Discover] fix functional test * [Discover] add test coverage for the issue * [Discover] add debug points for functional test * [Discover] try to get clipboard permissions * [Discover] fix functional test * [Discover] apply suggestion * [Discover] apply suggestion * [Discover] apply naming suggestion Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../main/services/discover_state.ts | 2 +- .../discover/public/utils/get_sharing_data.ts | 19 +++++-- .../functional/apps/discover/reporting.ts | 54 ++++++++++++++++++- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/plugins/discover/public/application/main/services/discover_state.ts b/src/plugins/discover/public/application/main/services/discover_state.ts index d3ef2aeff393fd..0d4c9477013796 100644 --- a/src/plugins/discover/public/application/main/services/discover_state.ts +++ b/src/plugins/discover/public/application/main/services/discover_state.ts @@ -315,7 +315,7 @@ export function setState(stateContainer: ReduxLikeStateContainer, newS /** * Helper function to compare 2 different filter states */ -export function isEqualFilters(filtersA: Filter[], filtersB: Filter[]) { +export function isEqualFilters(filtersA?: Filter[] | Filter, filtersB?: Filter[] | Filter) { if (!filtersA && !filtersB) { return true; } else if (!filtersA || !filtersB) { diff --git a/src/plugins/discover/public/utils/get_sharing_data.ts b/src/plugins/discover/public/utils/get_sharing_data.ts index b1c23e1d7bce71..6a74a54071faac 100644 --- a/src/plugins/discover/public/utils/get_sharing_data.ts +++ b/src/plugins/discover/public/utils/get_sharing_data.ts @@ -21,7 +21,7 @@ import { } from '../../common'; import type { SavedSearch, SortOrder } from '../services/saved_searches'; import { getSortForSearchSource } from '../components/doc_table'; -import { AppState } from '../application/main/services/discover_state'; +import { AppState, isEqualFilters } from '../application/main/services/discover_state'; /** * Preparing data to share the current state as link or CSV/Report @@ -34,7 +34,7 @@ export async function getSharingData( const { uiSettings: config, data } = services; const searchSource = currentSearchSource.createCopy(); const index = searchSource.getField('index')!; - const existingFilter = searchSource.getField('filter'); + let existingFilter = searchSource.getField('filter') as Filter[] | Filter | undefined; searchSource.setField( 'sort', @@ -62,11 +62,20 @@ export async function getSharingData( } } + const absoluteTimeFilter = data.query.timefilter.timefilter.createFilter(index); + const relativeTimeFilter = data.query.timefilter.timefilter.createRelativeFilter(index); return { getSearchSource: (absoluteTime?: boolean): SerializedSearchSourceFields => { - const timeFilter = absoluteTime - ? data.query.timefilter.timefilter.createFilter(index) - : data.query.timefilter.timefilter.createRelativeFilter(index); + const timeFilter = absoluteTime ? absoluteTimeFilter : relativeTimeFilter; + + // remove timeFilter from existing filter + if (Array.isArray(existingFilter)) { + existingFilter = existingFilter.filter( + (current) => !isEqualFilters(current, absoluteTimeFilter) + ); + } else if (isEqualFilters(existingFilter, absoluteTimeFilter)) { + existingFilter = undefined; + } if (existingFilter && timeFilter) { searchSource.setField( diff --git a/x-pack/test/functional/apps/discover/reporting.ts b/x-pack/test/functional/apps/discover/reporting.ts index c0e4ebc3f5f4f0..55282dd143b7f6 100644 --- a/x-pack/test/functional/apps/discover/reporting.ts +++ b/x-pack/test/functional/apps/discover/reporting.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { Key } from 'selenium-webdriver'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -17,8 +18,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const browser = getService('browser'); const retry = getService('retry'); - const PageObjects = getPageObjects(['reporting', 'common', 'discover', 'timePicker']); + const PageObjects = getPageObjects(['reporting', 'common', 'discover', 'timePicker', 'share']); const filterBar = getService('filterBar'); + const find = getService('find'); + const testSubjects = getService('testSubjects'); const setFieldsFromSource = async (setValue: boolean) => { await kibanaServer.uiSettings.update({ 'discover:searchFieldsFromSource': setValue }); @@ -76,6 +79,55 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.selectIndexPattern('ecommerce'); }); + it('generates a report with single timefilter', async () => { + await PageObjects.discover.clickNewSearchButton(); + await PageObjects.timePicker.setCommonlyUsedTime('Last_24 hours'); + await PageObjects.discover.saveSearch('single-timefilter-search'); + + // get shared URL value + await PageObjects.share.clickShareTopNavButton(); + const sharedURL = await PageObjects.share.getSharedUrl(); + + // click 'Copy POST URL' + await PageObjects.share.clickShareTopNavButton(); + await PageObjects.reporting.openCsvReportingPanel(); + const advOpt = await find.byXPath(`//button[descendant::*[text()='Advanced options']]`); + await advOpt.click(); + const postUrl = await find.byXPath(`//button[descendant::*[text()='Copy POST URL']]`); + await postUrl.click(); + + // get clipboard value using field search input, since + // 'browser.getClipboardValue()' doesn't work, due to permissions + const textInput = await testSubjects.find('fieldFilterSearchInput'); + await textInput.click(); + await browser.getActions().keyDown(Key.CONTROL).perform(); + await browser.getActions().keyDown('v').perform(); + + const reportURL = decodeURIComponent(await textInput.getAttribute('value')); + + // get number of filters in URLs + const timeFiltersNumberInReportURL = + reportURL.split('query:(range:(order_date:(format:strict_date_optional_time').length - 1; + const timeFiltersNumberInSharedURL = sharedURL.split('time:').length - 1; + + expect(timeFiltersNumberInSharedURL).to.be(1); + expect(sharedURL.includes('time:(from:now-24h%2Fh,to:now))')).to.be(true); + + expect(timeFiltersNumberInReportURL).to.be(1); + expect( + reportURL.includes( + 'query:(range:(order_date:(format:strict_date_optional_time,gte:now-24h/h,lte:now))))' + ) + ).to.be(true); + + // return keyboard state + await browser.getActions().keyUp(Key.CONTROL).perform(); + await browser.getActions().keyUp('v').perform(); + + // return field search input state + await textInput.clearValue(); + }); + it('generates a report from a new search with data: default', async () => { await PageObjects.discover.clickNewSearchButton(); await PageObjects.reporting.setTimepickerInEcommerceDataRange(); From c1704d9c9dc2eff8a0073f6c13ec10a78fb22eb8 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Wed, 16 Mar 2022 08:44:00 +0100 Subject: [PATCH 17/39] [Workplace Search] Add DocsLink for SharePoint Online external (#127798) --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + .../applications/shared/doc_links/doc_links.ts | 4 ++++ .../add_source/external_connector_config.tsx | 13 ++++++++++--- .../components/add_source/save_config.tsx | 7 ------- .../views/content_sources/source_data.tsx | 2 +- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index aaa15eb1eb312e..8c28cbfd2b9bb0 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -121,6 +121,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { customSourcePermissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-custom-api-sources.html#custom-api-source-document-level-access-control`, documentPermissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-sources-document-permissions.html`, dropbox: `${WORKPLACE_SEARCH_DOCS}workplace-search-dropbox-connector.html`, + externalSharePointOnline: `${WORKPLACE_SEARCH_DOCS}sharepoint-online-external.html`, externalIdentities: `${WORKPLACE_SEARCH_DOCS}workplace-search-external-identities-api.html`, gettingStarted: `${WORKPLACE_SEARCH_DOCS}workplace-search-getting-started.html`, gitHub: `${WORKPLACE_SEARCH_DOCS}workplace-search-github-connector.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 30a7d0fd783942..656dea5bc34e4b 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -111,6 +111,7 @@ export interface DocLinks { readonly customSourcePermissions: string; readonly documentPermissions: string; readonly dropbox: string; + readonly externalSharePointOnline: string; readonly externalIdentities: string; readonly gitHub: string; readonly gettingStarted: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts index b87f0434547cfa..f512a680efdfed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -44,6 +44,7 @@ class DocLinks { public workplaceSearchCustomSourcePermissions: string; public workplaceSearchDocumentPermissions: string; public workplaceSearchDropbox: string; + public workplaceSearchExternalSharePointOnline: string; public workplaceSearchExternalIdentities: string; public workplaceSearchGettingStarted: string; public workplaceSearchGitHub: string; @@ -99,6 +100,7 @@ class DocLinks { this.workplaceSearchCustomSourcePermissions = ''; this.workplaceSearchDocumentPermissions = ''; this.workplaceSearchDropbox = ''; + this.workplaceSearchExternalSharePointOnline = ''; this.workplaceSearchExternalIdentities = ''; this.workplaceSearchGettingStarted = ''; this.workplaceSearchGitHub = ''; @@ -156,6 +158,8 @@ class DocLinks { docLinks.links.workplaceSearch.customSourcePermissions; this.workplaceSearchDocumentPermissions = docLinks.links.workplaceSearch.documentPermissions; this.workplaceSearchDropbox = docLinks.links.workplaceSearch.dropbox; + this.workplaceSearchExternalSharePointOnline = + docLinks.links.workplaceSearch.externalSharePointOnline; this.workplaceSearchExternalIdentities = docLinks.links.workplaceSearch.externalIdentities; this.workplaceSearchGettingStarted = docLinks.links.workplaceSearch.gettingStarted; this.workplaceSearchGitHub = docLinks.links.workplaceSearch.gitHub; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx index d964d136a27ad0..fecb1095dc242a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx @@ -31,6 +31,7 @@ import { NAV, REMOVE_BUTTON } from '../../../../constants'; import { SourceDataItem } from '../../../../types'; import { AddSourceHeader } from './add_source_header'; +import { ConfigDocsLinks } from './config_docs_links'; import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants'; import { ExternalConnectorLogic } from './external_connector_logic'; @@ -40,7 +41,11 @@ interface SaveConfigProps { onDeleteConfig?: () => void; } -export const ExternalConnectorConfig: React.FC = ({ goBack, onDeleteConfig }) => { +export const ExternalConnectorConfig: React.FC = ({ + sourceData, + goBack, + onDeleteConfig, +}) => { const serviceType = 'external'; const { fetchExternalSource, @@ -67,6 +72,9 @@ export const ExternalConnectorConfig: React.FC = ({ goBack, onD }; const { name, categories } = sourceConfigData; + const { + configuration: { documentationUrl, applicationLinkTitle, applicationPortalUrl }, + } = sourceData; const { isOrganization } = useValues(AppLogic); const saveButton = ( @@ -97,13 +105,12 @@ export const ExternalConnectorConfig: React.FC = ({ goBack, onD const connectorForm = ( - {/* TODO: get a docs link in here for the external connector */} + /> = ({ const externalConnectorFields = ( <> - {/* TODO: get a docs link in here for the external connector - */} Date: Wed, 16 Mar 2022 09:12:08 +0100 Subject: [PATCH 18/39] [Unified observability] Fix refresh button in the overview page (#126927) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/application/application.test.tsx | 8 +- .../public/application/index.tsx | 9 +- .../components/app/section/apm/index.tsx | 11 +- .../components/app/section/logs/index.tsx | 12 +- .../components/app/section/metrics/index.tsx | 38 +++-- .../components/app/section/uptime/index.tsx | 11 +- .../components/app/section/ux/index.tsx | 20 ++- .../shared/date_picker/date_picker.test.tsx | 29 ++-- .../components/shared/date_picker/index.tsx | 54 ++---- .../public/context/date_picker_context.tsx | 156 ++++++++++++++++++ .../public/context/has_data_context.test.tsx | 18 -- .../public/context/has_data_context.tsx | 6 +- .../public/hooks/use_date_picker_context.ts | 13 ++ .../public/hooks/use_time_range.test.ts | 116 ------------- .../public/hooks/use_time_range.ts | 41 ----- x-pack/plugins/observability/public/index.ts | 1 + .../pages/overview/old_overview_page.tsx | 41 ++--- .../public/pages/overview/overview_page.tsx | 4 +- .../ping_timestamp/ping_timestamp.test.tsx | 11 +- .../step_screenshot_display.test.tsx | 20 ++- .../public/application/application.test.tsx | 14 +- .../plugins/ux/public/application/ux_app.tsx | 19 ++- .../app/rum_dashboard/hooks/use_ux_query.ts | 7 +- .../impactful_metrics/js_errors.tsx | 6 +- .../page_load_distribution/index.tsx | 5 +- .../rum_dashboard/page_views_trend/index.tsx | 6 +- .../panels/web_application_select.tsx | 5 +- .../rum_dashboard/visitor_breakdown/index.tsx | 6 +- .../visitor_breakdown_map/embedded_map.tsx | 4 +- 29 files changed, 354 insertions(+), 337 deletions(-) create mode 100644 x-pack/plugins/observability/public/context/date_picker_context.tsx create mode 100644 x-pack/plugins/observability/public/hooks/use_date_picker_context.ts delete mode 100644 x-pack/plugins/observability/public/hooks/use_time_range.test.ts delete mode 100644 x-pack/plugins/observability/public/hooks/use_time_range.ts diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index e2250962c671c7..ad164b86063bb9 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -34,7 +34,13 @@ describe('renderApp', () => { data: { query: { timefilter: { - timefilter: { setTime: jest.fn(), getTime: jest.fn().mockImplementation(() => ({})) }, + timefilter: { + setTime: jest.fn(), + getTime: jest.fn().mockReturnValue({}), + getTimeDefaults: jest.fn().mockReturnValue({}), + getRefreshInterval: jest.fn().mockReturnValue({}), + getRefreshIntervalDefaults: jest.fn().mockReturnValue({}), + }, }, }, }, diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index 69bf9cbe3ce40d..695838a646f808 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -19,6 +19,7 @@ import { } from '../../../../../src/plugins/kibana_react/public'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; import type { LazyObservabilityPageTemplateProps } from '../components/shared/page_template/lazy_page_template'; +import { DatePickerContextProvider } from '../context/date_picker_context'; import { HasDataContextProvider } from '../context/has_data_context'; import { PluginContext } from '../context/plugin_context'; import { useRouteParams } from '../hooks/use_route_params'; @@ -90,9 +91,11 @@ export const renderApp = ({ - - - + + + + + diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx index 6c61ecb3f270e6..8a96b8ccfa70c5 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx @@ -21,7 +21,7 @@ import moment from 'moment'; import React, { useContext } from 'react'; import { useHistory } from 'react-router-dom'; import { ThemeContext } from 'styled-components'; -import { useTimeRange } from '../../../../hooks/use_time_range'; +import { useDatePickerContext } from '../../../../hooks/use_date_picker_context'; import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; @@ -58,11 +58,12 @@ export function APMSection({ bucketSize }: Props) { const chartTheme = useChartTheme(); const history = useHistory(); const { forceUpdate, hasDataMap } = useHasData(); - const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd, lastUpdated } = + useDatePickerContext(); const { data, status } = useFetcher( () => { - if (bucketSize) { + if (bucketSize && absoluteStart && absoluteEnd) { return getDataHandler('apm')?.fetchData({ absoluteTime: { start: absoluteStart, end: absoluteEnd }, relativeTime: { start: relativeStart, end: relativeEnd }, @@ -70,9 +71,9 @@ export function APMSection({ bucketSize }: Props) { }); } }, - // Absolute times shouldn't be used here, since it would refetch on every render + // `forceUpdate` and `lastUpdated` should trigger a reload // eslint-disable-next-line react-hooks/exhaustive-deps - [bucketSize, relativeStart, relativeEnd, forceUpdate] + [bucketSize, relativeStart, relativeEnd, absoluteStart, absoluteEnd, forceUpdate, lastUpdated] ); if (!hasDataMap.apm?.hasData) { diff --git a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx index 78c23638a91bd2..80a14a3824e00d 100644 --- a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx @@ -26,7 +26,7 @@ import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; import { useHasData } from '../../../../hooks/use_has_data'; -import { useTimeRange } from '../../../../hooks/use_time_range'; +import { useDatePickerContext } from '../../../../hooks/use_date_picker_context'; import { LogsFetchDataResponse } from '../../../../typings'; import { formatStatValue } from '../../../../utils/format_stat_value'; import { ChartContainer } from '../../chart_container'; @@ -57,11 +57,12 @@ export function LogsSection({ bucketSize }: Props) { const history = useHistory(); const chartTheme = useChartTheme(); const { forceUpdate, hasDataMap } = useHasData(); - const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd, lastUpdated } = + useDatePickerContext(); const { data, status } = useFetcher( () => { - if (bucketSize) { + if (bucketSize && absoluteStart && absoluteEnd) { return getDataHandler('infra_logs')?.fetchData({ absoluteTime: { start: absoluteStart, end: absoluteEnd }, relativeTime: { start: relativeStart, end: relativeEnd }, @@ -69,9 +70,10 @@ export function LogsSection({ bucketSize }: Props) { }); } }, - // Absolute times shouldn't be used here, since it would refetch on every render + + // `forceUpdate` and `lastUpdated` trigger a reload // eslint-disable-next-line react-hooks/exhaustive-deps - [bucketSize, relativeStart, relativeEnd, forceUpdate] + [bucketSize, relativeStart, relativeEnd, absoluteStart, absoluteEnd, forceUpdate, lastUpdated] ); if (!hasDataMap.infra_logs?.hasData) { diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx index f7f35552fb6862..39757d79bab962 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx @@ -25,7 +25,7 @@ import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; import { useHasData } from '../../../../hooks/use_has_data'; -import { useTimeRange } from '../../../../hooks/use_time_range'; +import { useDatePickerContext } from '../../../../hooks/use_date_picker_context'; import { HostLink } from './host_link'; import { formatDuration } from './lib/format_duration'; import { MetricWithSparkline } from './metric_with_sparkline'; @@ -51,25 +51,31 @@ const bytesPerSecondFormatter = (value: NumberOrNull) => export function MetricsSection({ bucketSize }: Props) { const { forceUpdate, hasDataMap } = useHasData(); - const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd, lastUpdated } = + useDatePickerContext(); const [sortDirection, setSortDirection] = useState('asc'); const [sortField, setSortField] = useState('uptime'); const [sortedData, setSortedData] = useState(null); - const { data, status } = useFetcher( - () => { - if (bucketSize) { - return getDataHandler('infra_metrics')?.fetchData({ - absoluteTime: { start: absoluteStart, end: absoluteEnd }, - relativeTime: { start: relativeStart, end: relativeEnd }, - ...bucketSize, - }); - } - }, - // Absolute times shouldn't be used here, since it would refetch on every render + const { data, status } = useFetcher(() => { + if (bucketSize && absoluteStart && absoluteEnd) { + return getDataHandler('infra_metrics')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + ...bucketSize, + }); + } + // `forceUpdate` and `lastUpdated` should trigger a reload // eslint-disable-next-line react-hooks/exhaustive-deps - [bucketSize, relativeStart, relativeEnd, forceUpdate] - ); + }, [ + bucketSize, + relativeStart, + relativeEnd, + absoluteStart, + absoluteEnd, + forceUpdate, + lastUpdated, + ]); const handleTableChange = useCallback( ({ sort }: Criteria) => { @@ -125,7 +131,7 @@ export function MetricsSection({ bucketSize }: Props) { ), }, diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 5176b3f0721c8f..84779e5270e467 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -27,7 +27,7 @@ import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; import { useHasData } from '../../../../hooks/use_has_data'; -import { useTimeRange } from '../../../../hooks/use_time_range'; +import { useDatePickerContext } from '../../../../hooks/use_date_picker_context'; import { Series } from '../../../../typings'; import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; @@ -43,11 +43,12 @@ export function UptimeSection({ bucketSize }: Props) { const chartTheme = useChartTheme(); const history = useHistory(); const { forceUpdate, hasDataMap } = useHasData(); - const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd, lastUpdated } = + useDatePickerContext(); const { data, status } = useFetcher( () => { - if (bucketSize) { + if (bucketSize && absoluteStart && absoluteEnd) { return getDataHandler('synthetics')?.fetchData({ absoluteTime: { start: absoluteStart, end: absoluteEnd }, relativeTime: { start: relativeStart, end: relativeEnd }, @@ -55,9 +56,9 @@ export function UptimeSection({ bucketSize }: Props) { }); } }, - // Absolute times shouldn't be used here, since it would refetch on every render + // `forceUpdate` and `lastUpdated` should trigger a reload // eslint-disable-next-line react-hooks/exhaustive-deps - [bucketSize, relativeStart, relativeEnd, forceUpdate] + [bucketSize, relativeStart, relativeEnd, absoluteStart, absoluteEnd, forceUpdate, lastUpdated] ); if (!hasDataMap.synthetics?.hasData) { diff --git a/x-pack/plugins/observability/public/components/app/section/ux/index.tsx b/x-pack/plugins/observability/public/components/app/section/ux/index.tsx index 6863916f9bb8c4..4d1ff07c85a088 100644 --- a/x-pack/plugins/observability/public/components/app/section/ux/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/ux/index.tsx @@ -11,7 +11,7 @@ import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; import { useHasData } from '../../../../hooks/use_has_data'; -import { useTimeRange } from '../../../../hooks/use_time_range'; +import { useDatePickerContext } from '../../../../hooks/use_date_picker_context'; import CoreVitals from '../../../shared/core_web_vitals'; import { BucketSize } from '../../../../pages/overview'; @@ -21,13 +21,14 @@ interface Props { export function UXSection({ bucketSize }: Props) { const { forceUpdate, hasDataMap } = useHasData(); - const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd, lastUpdated } = + useDatePickerContext(); const uxHasDataResponse = hasDataMap.ux; const serviceName = uxHasDataResponse?.serviceName as string; const { data, status } = useFetcher( () => { - if (serviceName && bucketSize) { + if (serviceName && bucketSize && absoluteStart && absoluteEnd) { return getDataHandler('ux')?.fetchData({ absoluteTime: { start: absoluteStart, end: absoluteEnd }, relativeTime: { start: relativeStart, end: relativeEnd }, @@ -36,9 +37,18 @@ export function UXSection({ bucketSize }: Props) { }); } }, - // Absolute times shouldn't be used here, since it would refetch on every render + // `forceUpdate` and `lastUpdated` should trigger a reload // eslint-disable-next-line react-hooks/exhaustive-deps - [bucketSize, relativeStart, relativeEnd, forceUpdate, serviceName] + [ + bucketSize, + relativeStart, + relativeEnd, + absoluteStart, + absoluteEnd, + forceUpdate, + serviceName, + lastUpdated, + ] ); if (!uxHasDataResponse?.hasData) { diff --git a/x-pack/plugins/observability/public/components/shared/date_picker/date_picker.test.tsx b/x-pack/plugins/observability/public/components/shared/date_picker/date_picker.test.tsx index 53324e7df3af2a..713063e42eda60 100644 --- a/x-pack/plugins/observability/public/components/shared/date_picker/date_picker.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/date_picker/date_picker.test.tsx @@ -15,6 +15,7 @@ import qs from 'query-string'; import { DatePicker } from './'; import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; import { of } from 'rxjs'; +import { DatePickerContextProvider } from '../../../context/date_picker_context'; let history: MemoryHistory; @@ -69,7 +70,13 @@ function mountDatePicker(initialParams: { data: { query: { timefilter: { - timefilter: { setTime: setTimeSpy, getTime: getTimeSpy }, + timefilter: { + setTime: setTimeSpy, + getTime: getTimeSpy, + getTimeDefaults: jest.fn().mockReturnValue({}), + getRefreshIntervalDefaults: jest.fn().mockReturnValue({}), + getRefreshInterval: jest.fn().mockReturnValue({}), + }, }, }, }, @@ -79,7 +86,9 @@ function mountDatePicker(initialParams: { }, }} > - + + + ); @@ -106,7 +115,8 @@ describe('DatePicker', () => { rangeTo: 'now', }); - expect(mockHistoryReplace).toHaveBeenCalledTimes(0); + // It updates the URL when it doesn't contain the range. + expect(mockHistoryPush).toHaveBeenCalledTimes(1); wrapper.find(EuiSuperDatePicker).props().onTimeChange({ start: 'now-90m', @@ -114,7 +124,7 @@ describe('DatePicker', () => { isInvalid: false, isQuickSelection: true, }); - expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledTimes(2); expect(mockHistoryPush).toHaveBeenLastCalledWith( expect.objectContaining({ search: 'rangeFrom=now-90m&rangeTo=now-60m', @@ -152,17 +162,6 @@ describe('DatePicker', () => { }); describe('if both `rangeTo` and `rangeFrom` is set', () => { - it('calls setTime ', async () => { - const { setTimeSpy } = mountDatePicker({ - rangeTo: 'now-20m', - rangeFrom: 'now-22m', - }); - expect(setTimeSpy).toHaveBeenCalledWith({ - to: 'now-20m', - from: 'now-22m', - }); - }); - it('does not update the url', () => { expect(mockHistoryReplace).toHaveBeenCalledTimes(0); }); diff --git a/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx index ac32ad31d77b43..fde4b94460017b 100644 --- a/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx @@ -6,13 +6,10 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import React, { useEffect } from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import React, { useCallback } from 'react'; import { UI_SETTINGS, useKibanaUISettings } from '../../../hooks/use_kibana_ui_settings'; -import { fromQuery, toQuery } from '../../../utils/url'; import { TimePickerQuickRange } from './typings'; -import { ObservabilityPublicPluginsStart } from '../../../plugin'; +import { useDatePickerContext } from '../../../hooks/use_date_picker_context'; export interface DatePickerProps { rangeFrom?: string; @@ -29,19 +26,7 @@ export function DatePicker({ refreshInterval, onTimeRangeRefresh, }: DatePickerProps) { - const location = useLocation(); - const history = useHistory(); - const { data } = useKibana().services; - - useEffect(() => { - // set time if both to and from are given in the url - if (rangeFrom && rangeTo) { - data.query.timefilter.timefilter.setTime({ - from: rangeFrom, - to: rangeTo, - }); - } - }, [data, rangeFrom, rangeTo]); + const { updateTimeRange, updateRefreshInterval } = useDatePickerContext(); const timePickerQuickRanges = useKibanaUISettings( UI_SETTINGS.TIMEPICKER_QUICK_RANGES @@ -53,21 +38,6 @@ export function DatePicker({ label: display, })); - function updateUrl(nextQuery: { - rangeFrom?: string; - rangeTo?: string; - refreshPaused?: boolean; - refreshInterval?: number; - }) { - history.push({ - ...location, - search: fromQuery({ - ...toQuery(location.search), - ...nextQuery, - }), - }); - } - function onRefreshChange({ isPaused, refreshInterval: interval, @@ -75,23 +45,29 @@ export function DatePicker({ isPaused: boolean; refreshInterval: number; }) { - updateUrl({ refreshPaused: isPaused, refreshInterval: interval }); + updateRefreshInterval({ isPaused, interval }); } - function onTimeChange({ start, end }: { start: string; end: string }) { - updateUrl({ rangeFrom: start, rangeTo: end }); - } + const onRefresh = useCallback( + (newRange: { start: string; end: string }) => { + if (onTimeRangeRefresh) { + onTimeRangeRefresh(newRange); + } + updateTimeRange(newRange); + }, + [onTimeRangeRefresh, updateTimeRange] + ); return ( ); } diff --git a/x-pack/plugins/observability/public/context/date_picker_context.tsx b/x-pack/plugins/observability/public/context/date_picker_context.tsx new file mode 100644 index 00000000000000..711d0adc0e5226 --- /dev/null +++ b/x-pack/plugins/observability/public/context/date_picker_context.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useState, useMemo, useCallback } from 'react'; +import useMount from 'react-use/lib/useMount'; +import { useLocation, useHistory } from 'react-router-dom'; +import { parse } from 'query-string'; +import { fromQuery, ObservabilityPublicPluginsStart, toQuery } from '..'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { getAbsoluteTime } from '../utils/date'; + +export interface DatePickerContextValue { + relativeStart: string; + relativeEnd: string; + absoluteStart?: number; + absoluteEnd?: number; + refreshInterval: number; + refreshPaused: boolean; + updateTimeRange: (params: { start: string; end: string }) => void; + updateRefreshInterval: (params: { interval: number; isPaused: boolean }) => void; + lastUpdated: number; +} + +/** + * This context contains the time range (both relative and absolute) and the + * autorefresh status of the overview page date picker. + * It also updates the URL when any of the values change + */ +export const DatePickerContext = createContext({} as DatePickerContextValue); + +export function DatePickerContextProvider({ children }: { children: React.ReactElement }) { + const location = useLocation(); + const history = useHistory(); + + const updateUrl = useCallback( + (nextQuery: { + rangeFrom?: string; + rangeTo?: string; + refreshPaused?: boolean; + refreshInterval?: number; + }) => { + history.push({ + ...location, + search: fromQuery({ + ...toQuery(location.search), + ...nextQuery, + }), + }); + }, + [history, location] + ); + + const [lastUpdated, setLastUpdated] = useState(Date.now()); + + const { data } = useKibana().services; + + const defaultTimeRange = data.query.timefilter.timefilter.getTimeDefaults(); + const sharedTimeRange = data.query.timefilter.timefilter.getTime(); + const defaultRefreshInterval = data.query.timefilter.timefilter.getRefreshIntervalDefaults(); + const sharedRefreshInterval = data.query.timefilter.timefilter.getRefreshInterval(); + + const { + rangeFrom = sharedTimeRange.from ?? defaultTimeRange.from, + rangeTo = sharedTimeRange.to ?? defaultTimeRange.to, + refreshInterval = sharedRefreshInterval.value || defaultRefreshInterval.value || 10000, // we want to override a default of 0 + refreshPaused = sharedRefreshInterval.pause ?? defaultRefreshInterval.pause, + } = parse(location.search, { + sort: false, + }); + + const relativeStart = rangeFrom as string; + const relativeEnd = rangeTo as string; + + const absoluteStart = useMemo( + () => getAbsoluteTime(relativeStart), + // `lastUpdated` works as a cache buster + // eslint-disable-next-line react-hooks/exhaustive-deps + [relativeStart, lastUpdated] + ); + + const absoluteEnd = useMemo( + () => getAbsoluteTime(relativeEnd, { roundUp: true }), + // `lastUpdated` works as a cache buster + // eslint-disable-next-line react-hooks/exhaustive-deps + [relativeEnd, lastUpdated] + ); + + const updateTimeRange = useCallback( + ({ start, end }: { start: string; end: string }) => { + data.query.timefilter.timefilter.setTime({ from: start, to: end }); + updateUrl({ rangeFrom: start, rangeTo: end }); + setLastUpdated(Date.now()); + }, + [data.query.timefilter.timefilter, updateUrl] + ); + + const updateRefreshInterval = useCallback( + ({ interval, isPaused }) => { + updateUrl({ refreshInterval: interval, refreshPaused: isPaused }); + data.query.timefilter.timefilter.setRefreshInterval({ value: interval, pause: isPaused }); + setLastUpdated(Date.now()); + }, + [data.query.timefilter.timefilter, updateUrl] + ); + + useMount(() => { + updateUrl({ rangeFrom: relativeStart, rangeTo: relativeEnd }); + }); + + return ( + + {children} + + ); +} + +function parseRefreshInterval(value: string | string[] | number | null): number { + switch (typeof value) { + case 'number': + return value; + case 'string': + return parseInt(value, 10) || 0; + default: + return 0; + } +} + +function parseRefreshPaused(value: string | string[] | boolean | null): boolean { + if (typeof value === 'boolean') { + return value; + } + + switch (value) { + case 'false': + return false; + case 'true': + default: + return true; + } +} diff --git a/x-pack/plugins/observability/public/context/has_data_context.test.tsx b/x-pack/plugins/observability/public/context/has_data_context.test.tsx index 4d4c96d1c11104..8e33b6d329d49e 100644 --- a/x-pack/plugins/observability/public/context/has_data_context.test.tsx +++ b/x-pack/plugins/observability/public/context/has_data_context.test.tsx @@ -10,8 +10,6 @@ import { CoreStart } from 'kibana/public'; import React from 'react'; import { registerDataHandler, unregisterDataHandler } from '../data_handler'; import { useHasData } from '../hooks/use_has_data'; -import * as routeParams from '../hooks/use_route_params'; -import * as timeRange from '../hooks/use_time_range'; import { HasData, ObservabilityFetchDataPlugins } from '../typings/fetch_overview_data'; import { HasDataContextProvider } from './has_data_context'; import * as pluginContext from '../hooks/use_plugin_context'; @@ -21,9 +19,6 @@ import { createMemoryHistory } from 'history'; import { ApmIndicesConfig } from '../../common/typings'; import { act } from '@testing-library/react'; -const relativeStart = '2020-10-08T06:00:00.000Z'; -const relativeEnd = '2020-10-08T07:00:00.000Z'; - const sampleAPMIndices = { transaction: 'apm-*' } as ApmIndicesConfig; function wrapper({ children }: { children: React.ReactElement }) { @@ -57,19 +52,6 @@ function registerApps( describe('HasDataContextProvider', () => { beforeAll(() => { - jest.spyOn(routeParams, 'useRouteParams').mockImplementation(() => ({ - query: { - from: relativeStart, - to: relativeEnd, - }, - path: {}, - })); - jest.spyOn(timeRange, 'useTimeRange').mockImplementation(() => ({ - relativeStart, - relativeEnd, - absoluteStart: new Date(relativeStart).valueOf(), - absoluteEnd: new Date(relativeEnd).valueOf(), - })); jest.spyOn(pluginContext, 'usePluginContext').mockReturnValue({ core: { http: { get: jest.fn() } } as unknown as CoreStart, } as PluginContextValue); diff --git a/x-pack/plugins/observability/public/context/has_data_context.tsx b/x-pack/plugins/observability/public/context/has_data_context.tsx index f9bb1837257271..240f6a0a8be2bc 100644 --- a/x-pack/plugins/observability/public/context/has_data_context.tsx +++ b/x-pack/plugins/observability/public/context/has_data_context.tsx @@ -12,7 +12,7 @@ import { asyncForEach } from '@kbn/std'; import { getDataHandler } from '../data_handler'; import { FETCH_STATUS } from '../hooks/use_fetcher'; import { usePluginContext } from '../hooks/use_plugin_context'; -import { useTimeRange } from '../hooks/use_time_range'; +import { useDatePickerContext } from '../hooks/use_date_picker_context'; import { getObservabilityAlerts } from '../services/get_observability_alerts'; import { ObservabilityFetchDataPlugins } from '../typings/fetch_overview_data'; import { ApmIndicesConfig } from '../../common/typings'; @@ -44,7 +44,7 @@ const apps: DataContextApps[] = ['apm', 'synthetics', 'infra_logs', 'infra_metri export function HasDataContextProvider({ children }: { children: React.ReactNode }) { const { core } = usePluginContext(); const [forceUpdate, setForceUpdate] = useState(''); - const { absoluteStart, absoluteEnd } = useTimeRange(); + const { absoluteStart, absoluteEnd } = useDatePickerContext(); const [hasDataMap, setHasDataMap] = useState({}); @@ -76,7 +76,7 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode }; switch (app) { case 'ux': - const params = { absoluteTime: { start: absoluteStart, end: absoluteEnd } }; + const params = { absoluteTime: { start: absoluteStart!, end: absoluteEnd! } }; const resultUx = await getDataHandler(app)?.hasData(params); updateState({ hasData: resultUx?.hasData, diff --git a/x-pack/plugins/observability/public/hooks/use_date_picker_context.ts b/x-pack/plugins/observability/public/hooks/use_date_picker_context.ts new file mode 100644 index 00000000000000..e4d42d4e25f32b --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_date_picker_context.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useContext } from 'react'; +import { DatePickerContext } from '../context/date_picker_context'; + +export function useDatePickerContext() { + return useContext(DatePickerContext); +} diff --git a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts deleted file mode 100644 index 246aa42820b524..00000000000000 --- a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useTimeRange } from './use_time_range'; -import * as pluginContext from './use_plugin_context'; -import { AppMountParameters, CoreStart } from 'kibana/public'; -import { ObservabilityPublicPluginsStart } from '../plugin'; -import * as kibanaUISettings from './use_kibana_ui_settings'; -import { createObservabilityRuleTypeRegistryMock } from '../rules/observability_rule_type_registry_mock'; - -jest.mock('react-router-dom', () => ({ - useLocation: () => ({ - pathname: '/observability/overview/', - search: '', - }), -})); - -describe('useTimeRange', () => { - beforeAll(() => { - jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ - core: {} as CoreStart, - appMountParameters: {} as AppMountParameters, - config: { - unsafe: { - alertingExperience: { enabled: true }, - cases: { enabled: true }, - overviewNext: { enabled: false }, - rules: { enabled: false }, - }, - }, - plugins: { - data: { - query: { - timefilter: { - timefilter: { - getTime: jest.fn().mockImplementation(() => ({ - from: '2020-10-08T06:00:00.000Z', - to: '2020-10-08T07:00:00.000Z', - })), - }, - }, - }, - }, - } as unknown as ObservabilityPublicPluginsStart, - observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), - ObservabilityPageTemplate: () => null, - })); - jest.spyOn(kibanaUISettings, 'useKibanaUISettings').mockImplementation(() => ({ - from: '2020-10-08T05:00:00.000Z', - to: '2020-10-08T06:00:00.000Z', - })); - }); - - describe('when range from and to are not provided', () => { - describe('when data plugin has time set', () => { - it('returns ranges and absolute times from data plugin', () => { - const relativeStart = '2020-10-08T06:00:00.000Z'; - const relativeEnd = '2020-10-08T07:00:00.000Z'; - const timeRange = useTimeRange(); - expect(timeRange).toEqual({ - relativeStart, - relativeEnd, - absoluteStart: new Date(relativeStart).valueOf(), - absoluteEnd: new Date(relativeEnd).valueOf(), - }); - }); - }); - describe("when data plugin doesn't have time set", () => { - beforeAll(() => { - jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ - core: {} as CoreStart, - appMountParameters: {} as AppMountParameters, - config: { - unsafe: { - alertingExperience: { enabled: true }, - cases: { enabled: true }, - overviewNext: { enabled: false }, - rules: { enabled: false }, - }, - }, - plugins: { - data: { - query: { - timefilter: { - timefilter: { - getTime: jest.fn().mockImplementation(() => ({ - from: undefined, - to: undefined, - })), - }, - }, - }, - }, - } as unknown as ObservabilityPublicPluginsStart, - observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), - ObservabilityPageTemplate: () => null, - })); - }); - it('returns ranges and absolute times from kibana default settings', () => { - const relativeStart = '2020-10-08T05:00:00.000Z'; - const relativeEnd = '2020-10-08T06:00:00.000Z'; - const timeRange = useTimeRange(); - expect(timeRange).toEqual({ - relativeStart, - relativeEnd, - absoluteStart: new Date(relativeStart).valueOf(), - absoluteEnd: new Date(relativeEnd).valueOf(), - }); - }); - }); - }); -}); diff --git a/x-pack/plugins/observability/public/hooks/use_time_range.ts b/x-pack/plugins/observability/public/hooks/use_time_range.ts deleted file mode 100644 index aa120d6968bfb7..00000000000000 --- a/x-pack/plugins/observability/public/hooks/use_time_range.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { parse } from 'query-string'; -import { useLocation } from 'react-router-dom'; -import { TimePickerTimeDefaults } from '../components/shared/date_picker/typings'; -import { getAbsoluteTime } from '../utils/date'; -import { UI_SETTINGS, useKibanaUISettings } from './use_kibana_ui_settings'; -import { usePluginContext } from './use_plugin_context'; - -const getParsedParams = (search: string) => { - return parse(search.slice(1), { sort: false }); -}; - -export function useTimeRange() { - const { plugins } = usePluginContext(); - - const timePickerTimeDefaults = useKibanaUISettings( - UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS - ); - - const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); - - const { rangeFrom, rangeTo } = getParsedParams(useLocation().search); - - const relativeStart = (rangeFrom ?? - timePickerSharedState.from ?? - timePickerTimeDefaults.from) as string; - const relativeEnd = (rangeTo ?? timePickerSharedState.to ?? timePickerTimeDefaults.to) as string; - - return { - relativeStart, - relativeEnd, - absoluteStart: getAbsoluteTime(relativeStart)!, - absoluteEnd: getAbsoluteTime(relativeEnd, { roundUp: true })!, - }; -} diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 80a2704b5e7609..73290929088c93 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -52,6 +52,7 @@ export const plugin: PluginInitializer< export * from './components/shared/action_menu/'; export type { UXMetrics } from './components/shared/core_web_vitals/'; +export { DatePickerContextProvider } from './context/date_picker_context'; export { getCoreVitalsComponent, HeaderMenuPortal, diff --git a/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx b/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx index 75d2e4f8aefaec..fe241e0d80f2c2 100644 --- a/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx +++ b/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx @@ -20,7 +20,6 @@ import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; import { useFetcher } from '../../hooks/use_fetcher'; import { useHasData } from '../../hooks/use_has_data'; import { usePluginContext } from '../../hooks/use_plugin_context'; -import { useTimeRange } from '../../hooks/use_time_range'; import { useAlertIndexNames } from '../../hooks/use_alert_index_names'; import { RouteParams } from '../../routes'; import { getNewsFeed } from '../../services/get_news_feed'; @@ -32,6 +31,7 @@ import { AlertsTableTGrid } from '../alerts/containers/alerts_table_t_grid/alert import { SectionContainer } from '../../components/app/section'; import { ObservabilityAppServices } from '../../application/types'; import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; +import { useDatePickerContext } from '../../hooks/use_date_picker_context'; interface Props { routeParams: RouteParams<'/overview'>; } @@ -57,29 +57,22 @@ export function OverviewPage({ routeParams }: Props) { const { core, ObservabilityPageTemplate } = usePluginContext(); - const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); - - const relativeTime = { start: relativeStart, end: relativeEnd }; - const absoluteTime = { start: absoluteStart, end: absoluteEnd }; + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd, refreshInterval, refreshPaused } = + useDatePickerContext(); const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), [core]); const { hasAnyData, isAllRequestsComplete } = useHasData(); const refetch = useRef<() => void>(); - const bucketSize = calculateBucketSize({ - start: absoluteTime.start, - end: absoluteTime.end, - }); - - const bucketSizeValue = useMemo(() => { - if (bucketSize?.bucketSize) { - return { - bucketSize: bucketSize.bucketSize, - intervalString: bucketSize.intervalString, - }; - } - }, [bucketSize?.bucketSize, bucketSize?.intervalString]); + const bucketSize = useMemo( + () => + calculateBucketSize({ + start: absoluteStart, + end: absoluteEnd, + }), + [absoluteStart, absoluteEnd] + ); const setRefetch = useCallback((ref) => { refetch.current = ref; @@ -105,8 +98,6 @@ export function OverviewPage({ routeParams }: Props) { docsLink: core.docLinks.links.observability.guide, }); - const { refreshInterval = 10000, refreshPaused = true } = routeParams.query; - return ( @@ -155,7 +146,7 @@ export function OverviewPage({ routeParams }: Props) { {/* Data sections */} - {hasAnyData && } + {hasAnyData && } diff --git a/x-pack/plugins/observability/public/pages/overview/overview_page.tsx b/x-pack/plugins/observability/public/pages/overview/overview_page.tsx index d75f9b0573aaee..bc8e72f28ec104 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview_page.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview_page.tsx @@ -12,7 +12,7 @@ import { DatePicker } from '../../components/shared/date_picker'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; import { useHasData } from '../../hooks/use_has_data'; import { usePluginContext } from '../../hooks/use_plugin_context'; -import { useTimeRange } from '../../hooks/use_time_range'; +import { useDatePickerContext } from '../../hooks/use_date_picker_context'; import { RouteParams } from '../../routes'; import { getNoDataConfig } from '../../utils/no_data_config'; import { LoadingObservability } from './loading_observability'; @@ -36,7 +36,7 @@ export function OverviewPage({ routeParams }: Props) { const { core, ObservabilityPageTemplate } = usePluginContext(); - const { relativeStart, relativeEnd } = useTimeRange(); + const { relativeStart, relativeEnd } = useDatePickerContext(); const relativeTime = { start: relativeStart, end: relativeEnd }; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx index ed74b502add112..1e853bf3461dcb 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx @@ -16,16 +16,9 @@ import moment from 'moment'; import '../../../../../lib/__mocks__/use_composite_image.mock'; import { mockRef } from '../../../../../lib/__mocks__/screenshot_ref.mock'; -mockReduxHooks(); - -jest.mock('../../../../../../../observability/public', () => { - const originalModule = jest.requireActual('../../../../../../../observability/public'); +jest.mock('../../../../../../../observability/public'); - return { - ...originalModule, - useFetcher: jest.fn().mockReturnValue({ data: null, status: 'pending' }), - }; -}); +mockReduxHooks(); describe('Ping Timestamp component', () => { let checkGroup: string; diff --git a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx index 5b86ed525bc31f..ad8666f77ce6b8 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx @@ -12,20 +12,24 @@ import * as observabilityPublic from '../../../../observability/public'; import '../../lib/__mocks__/use_composite_image.mock'; import { mockRef } from '../../lib/__mocks__/screenshot_ref.mock'; -jest.mock('../../../../observability/public', () => { - const originalModule = jest.requireActual('../../../../observability/public'); - - return { - ...originalModule, - useFetcher: jest.fn().mockReturnValue({ data: null, status: 'success' }), - }; -}); +jest.mock('../../../../observability/public'); jest.mock('react-use/lib/useIntersection', () => () => ({ isIntersecting: true, })); describe('StepScreenshotDisplayProps', () => { + beforeAll(() => { + jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({ + data: null, + status: observabilityPublic.FETCH_STATUS.SUCCESS, + refetch: () => {}, + }); + }); + + afterAll(() => { + (observabilityPublic.useFetcher as any).mockClear(); + }); it('displays screenshot thumbnail when present', () => { const { getByAltText } = render( - - - - - - - - + + + + + + + + + + diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/hooks/use_ux_query.ts b/x-pack/plugins/ux/public/components/app/rum_dashboard/hooks/use_ux_query.ts index e3f697e02a2952..3a2106a079d4aa 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/hooks/use_ux_query.ts +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/hooks/use_ux_query.ts @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; export function useUxQuery() { - const { urlParams, uxUiFilters } = useLegacyUrlParams(); + const { rangeId, urlParams, uxUiFilters } = useLegacyUrlParams(); const { start, end, searchTerm, percentile } = urlParams; @@ -27,7 +27,10 @@ export function useUxQuery() { } return null; - }, [start, end, searchTerm, percentile, uxUiFilters]); + + // `rangeId` acts as a cache buster for stable date ranges like `Today` + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [start, end, searchTerm, percentile, uxUiFilters, rangeId]); return queryParams; } diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/impactful_metrics/js_errors.tsx b/x-pack/plugins/ux/public/components/app/rum_dashboard/impactful_metrics/js_errors.tsx index c8dada5dce40b2..760ed2cba53902 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/impactful_metrics/js_errors.tsx +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/impactful_metrics/js_errors.tsx @@ -32,7 +32,7 @@ interface JSErrorItem { } export function JSErrors() { - const { urlParams, uxUiFilters } = useLegacyUrlParams(); + const { rangeId, urlParams, uxUiFilters } = useLegacyUrlParams(); const { start, end, serviceName, searchTerm } = urlParams; @@ -56,7 +56,9 @@ export function JSErrors() { } return Promise.resolve(null); }, - [start, end, serviceName, uxUiFilters, pagination, searchTerm] + // `rangeId` acts as a cache buster for stable ranges like "Today" + // eslint-disable-next-line react-hooks/exhaustive-deps + [start, end, serviceName, uxUiFilters, pagination, searchTerm, rangeId] ); const { diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/page_load_distribution/index.tsx b/x-pack/plugins/ux/public/components/app/rum_dashboard/page_load_distribution/index.tsx index ef32ad53b3ccfa..dd3c05b7084c15 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/page_load_distribution/index.tsx +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/page_load_distribution/index.tsx @@ -32,7 +32,7 @@ export interface PercentileRange { export function PageLoadDistribution() { const { http } = useKibanaServices(); - const { urlParams, uxUiFilters } = useLegacyUrlParams(); + const { rangeId, urlParams, uxUiFilters } = useLegacyUrlParams(); const { start, end, rangeFrom, rangeTo, searchTerm } = urlParams; @@ -67,6 +67,8 @@ export function PageLoadDistribution() { } return Promise.resolve(null); }, + // `rangeId` acts as a cache buster for stable ranges like "Today" + // eslint-disable-next-line react-hooks/exhaustive-deps [ end, start, @@ -75,6 +77,7 @@ export function PageLoadDistribution() { percentileRange.max, searchTerm, serviceName, + rangeId, ] ); diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/page_views_trend/index.tsx b/x-pack/plugins/ux/public/components/app/rum_dashboard/page_views_trend/index.tsx index 48cf17089edcb0..062332f680d82a 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/page_views_trend/index.tsx +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/page_views_trend/index.tsx @@ -26,7 +26,7 @@ import { BreakdownItem } from '../../../../../typings/ui_filters'; export function PageViewsTrend() { const { http } = useKibanaServices(); - const { urlParams, uxUiFilters } = useLegacyUrlParams(); + const { rangeId, urlParams, uxUiFilters } = useLegacyUrlParams(); const { serviceName } = uxUiFilters; const { start, end, searchTerm, rangeTo, rangeFrom } = urlParams; @@ -54,7 +54,9 @@ export function PageViewsTrend() { } return Promise.resolve(undefined); }, - [start, end, serviceName, uxUiFilters, searchTerm, breakdown] + // `rangeId` acts as a cache buster for stable ranges like "Today" + // eslint-disable-next-line react-hooks/exhaustive-deps + [start, end, serviceName, uxUiFilters, searchTerm, breakdown, rangeId] ); const exploratoryViewLink = createExploratoryViewUrl( diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/panels/web_application_select.tsx b/x-pack/plugins/ux/public/components/app/rum_dashboard/panels/web_application_select.tsx index e7d86db0557a5a..1985f20a8d5c4e 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/panels/web_application_select.tsx +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/panels/web_application_select.tsx @@ -13,6 +13,7 @@ import { RUM_AGENT_NAMES } from '../../../../../common/agent_name'; export function WebApplicationSelect() { const { + rangeId, urlParams: { start, end }, } = useLegacyUrlParams(); @@ -30,7 +31,9 @@ export function WebApplicationSelect() { }); } }, - [start, end] + // `rangeId` works as a cache buster for ranges that never change, like `Today` + // eslint-disable-next-line react-hooks/exhaustive-deps + [start, end, rangeId] ); const rumServiceNames = data?.rumServices ?? []; diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/visitor_breakdown/index.tsx b/x-pack/plugins/ux/public/components/app/rum_dashboard/visitor_breakdown/index.tsx index ccf65025759914..a6be5a8340d080 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/visitor_breakdown/index.tsx +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/visitor_breakdown/index.tsx @@ -13,7 +13,7 @@ import { useFetcher } from '../../../../hooks/use_fetcher'; import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; export function VisitorBreakdown() { - const { urlParams, uxUiFilters } = useLegacyUrlParams(); + const { rangeId, urlParams, uxUiFilters } = useLegacyUrlParams(); const { start, end, searchTerm } = urlParams; @@ -35,7 +35,9 @@ export function VisitorBreakdown() { } return Promise.resolve(null); }, - [end, start, uxUiFilters, searchTerm] + // `rangeId` acts as a cache buster for stable ranges like "Today" + // eslint-disable-next-line react-hooks/exhaustive-deps + [end, start, uxUiFilters, searchTerm, rangeId] ); return ( diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/visitor_breakdown_map/embedded_map.tsx b/x-pack/plugins/ux/public/components/app/rum_dashboard/visitor_breakdown_map/embedded_map.tsx index 55845c44e5e274..780682d66ae3b8 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/visitor_breakdown_map/embedded_map.tsx +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/visitor_breakdown_map/embedded_map.tsx @@ -45,7 +45,7 @@ const EmbeddedPanel = styled.div` `; export function EmbeddedMapComponent() { - const { urlParams } = useLegacyUrlParams(); + const { rangeId, urlParams } = useLegacyUrlParams(); const { start, end, serviceName } = urlParams; @@ -124,7 +124,7 @@ export function EmbeddedMapComponent() { embeddable.reload(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [start, end]); + }, [start, end, rangeId]); useEffect(() => { async function setupEmbeddable() { From 11451adb63f0e342b29c90db170ebc1324fa43cf Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Wed, 16 Mar 2022 13:14:11 +0500 Subject: [PATCH 19/39] [Lens] Duplicated datatable available as inspector data for Heatmap chart (#126786) * [Lens] Duplicated datatable available as inspector data for Heatmap chart Close: #123176 * update workspace_panel * revert allowCsvExport * fix CI * fix PR comments * apply pr comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../expressions/common/util/tables_adapter.ts | 7 +++++- .../expressions/datatable/datatable_fn.ts | 5 ++++ .../common/expressions/expressions_utils.ts | 16 +++++++++++++ .../common/expressions/merge_tables/index.ts | 12 ++++++---- .../merge_tables/merge_tables.test.ts | 14 +++++------ .../common/expressions/xy_chart/xy_chart.ts | 4 ++++ .../workspace_panel/workspace_panel.test.tsx | 2 +- .../workspace_panel/workspace_panel.tsx | 24 +++++++++++++++---- 8 files changed, 66 insertions(+), 18 deletions(-) create mode 100644 x-pack/plugins/lens/common/expressions/expressions_utils.ts diff --git a/src/plugins/expressions/common/util/tables_adapter.ts b/src/plugins/expressions/common/util/tables_adapter.ts index 890c5601d117ab..1feba80b4c43d4 100644 --- a/src/plugins/expressions/common/util/tables_adapter.ts +++ b/src/plugins/expressions/common/util/tables_adapter.ts @@ -7,7 +7,7 @@ */ import { EventEmitter } from 'events'; -import { Datatable } from '../expression_types/specs'; +import type { Datatable } from '../expression_types/specs'; export class TablesAdapter extends EventEmitter { private _tables: { [key: string]: Datatable } = {}; @@ -17,6 +17,11 @@ export class TablesAdapter extends EventEmitter { this.emit('change', this.tables); } + public reset() { + this._tables = {}; + this.emit('change', this.tables); + } + public get tables() { return this._tables; } diff --git a/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts b/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts index 9d9e666e127620..41bf51764b5396 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts @@ -15,6 +15,7 @@ import type { ExecutionContext, } from '../../../../../../src/plugins/expressions'; import type { DatatableExpressionFunction } from './types'; +import { logDataTable } from '../expressions_utils'; function isRange(meta: { params?: { id?: string } } | undefined) { return meta?.params?.id === 'range'; @@ -25,6 +26,10 @@ export const datatableFn = getFormatFactory: (context: ExecutionContext) => FormatFactory | Promise ): DatatableExpressionFunction['fn'] => async (data, args, context) => { + if (context?.inspectorAdapters?.tables) { + logDataTable(context.inspectorAdapters.tables, data.tables); + } + let untransposedData: LensMultiTable | undefined; // do the sorting at this level to propagate it also at CSV download const [firstTable] = Object.values(data.tables); diff --git a/x-pack/plugins/lens/common/expressions/expressions_utils.ts b/x-pack/plugins/lens/common/expressions/expressions_utils.ts new file mode 100644 index 00000000000000..795b23e26e8304 --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/expressions_utils.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TablesAdapter } from '../../../../../src/plugins/expressions'; +import type { Datatable } from '../../../../../src/plugins/expressions'; + +export const logDataTable = ( + tableAdapter: TablesAdapter, + datatables: Record = {} +) => { + Object.entries(datatables).forEach(([key, table]) => tableAdapter.logDatatable(key, table)); +}; diff --git a/x-pack/plugins/lens/common/expressions/merge_tables/index.ts b/x-pack/plugins/lens/common/expressions/merge_tables/index.ts index 7ede2236e8b071..2bf955d9e3ed38 100644 --- a/x-pack/plugins/lens/common/expressions/merge_tables/index.ts +++ b/x-pack/plugins/lens/common/expressions/merge_tables/index.ts @@ -50,14 +50,16 @@ export const mergeTables: ExpressionFunctionDefinition< inputTypes: ['kibana_context', 'null'], fn(input, { layerIds, tables }, context) { const resultTables: Record = {}; + + if (context.inspectorAdapters?.tables) { + context.inspectorAdapters.tables.reset(); + context.inspectorAdapters.tables.allowCsvExport = true; + } + tables.forEach((table, index) => { resultTables[layerIds[index]] = table; - // adapter is always defined at that point because we make sure by the beginning of the function - if (context?.inspectorAdapters?.tables) { - context.inspectorAdapters.tables.allowCsvExport = true; - context.inspectorAdapters.tables.logDatatable(layerIds[index], table); - } }); + return { type: 'lens_multitable', tables: resultTables, diff --git a/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts b/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts index cd4fc5ed945d4a..1642f5520c3400 100644 --- a/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts +++ b/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts @@ -54,17 +54,17 @@ describe('lens_merge_tables', () => { }); }); - it('should store the current tables in the tables inspector', () => { - const adapters: DefaultInspectorAdapters = { + it('should reset the current tables in the tables inspector', () => { + const adapters = { tables: new TablesAdapter(), - requests: {} as never, - expression: {} as never, - }; + } as DefaultInspectorAdapters; + + const resetSpy = jest.spyOn(adapters.tables, 'reset'); + mergeTables.fn(null, { layerIds: ['first', 'second'], tables: [sampleTable1, sampleTable2] }, { inspectorAdapters: adapters, } as ExecutionContext); - expect(adapters.tables!.tables.first).toBe(sampleTable1); - expect(adapters.tables!.tables.second).toBe(sampleTable2); + expect(resetSpy).toHaveBeenCalled(); }); it('should pass the date range along', () => { diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts index d3fb2fe7a69170..481494d52966f2 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts @@ -10,6 +10,7 @@ import type { ExpressionValueSearchContext } from '../../../../../../src/plugins import type { LensMultiTable } from '../../types'; import type { XYArgs } from './xy_args'; import { fittingFunctionDefinitions } from './fitting_function'; +import { logDataTable } from '../expressions_utils'; export interface XYChartProps { data: LensMultiTable; @@ -157,6 +158,9 @@ export const xyChart: ExpressionFunctionDefinition< }, }, fn(data: LensMultiTable, args: XYArgs, handlers) { + if (handlers?.inspectorAdapters?.tables) { + logDataTable(handlers.inspectorAdapters.tables, data.tables); + } return { type: 'render', as: 'lens_xy_chart_renderer', diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index 7359f7cdc185b2..c3854f1a67809c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -410,7 +410,7 @@ describe('workspace_panel', () => { first: mockDatasource.publicAPIMock, }; mockDatasource.toExpression.mockReturnValue('datasource'); - mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource.getLayers.mockReturnValue(['table1']); const mounted = await mountWithProvider( ); @@ -455,6 +459,7 @@ export const VisualizationWrapper = ({ ExpressionRendererComponent, application, activeDatasourceId, + datasourceMap, }: { expression: string | null | undefined; framePublicAPI: FramePublicAPI; @@ -473,6 +478,7 @@ export const VisualizationWrapper = ({ ExpressionRendererComponent: ReactExpressionRendererType; application: ApplicationStart; activeDatasourceId: string | null; + datasourceMap: DatasourceMap; }) => { const context = useLensSelector(selectExecutionContext); const searchContext: ExecutionContextSearch = useMemo( @@ -487,16 +493,26 @@ export const VisualizationWrapper = ({ [context] ); const searchSessionId = useLensSelector(selectSearchSessionId); - + const datasourceLayers = useLensSelector((state) => selectDatasourceLayers(state, datasourceMap)); const dispatchLens = useLensDispatch(); + const [defaultLayerId] = Object.keys(datasourceLayers); const onData$ = useCallback( (data: unknown, adapters?: Partial) => { if (adapters && adapters.tables) { - dispatchLens(onActiveDataChange({ ...adapters.tables.tables })); + dispatchLens( + onActiveDataChange( + Object.entries(adapters.tables?.tables).reduce>( + (acc, [key, value], index, tables) => ({ + [tables.length === 1 ? defaultLayerId : key]: value, + }), + {} + ) + ) + ); } }, - [dispatchLens] + [defaultLayerId, dispatchLens] ); function renderFixAction( From 44178da9520d08bfb3ce3ee7729e89c2345e311d Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Wed, 16 Mar 2022 09:20:14 +0100 Subject: [PATCH 20/39] [Lens] Refactor dimension editor components to be sharable for annotations (#127164) * moving dimensionTrigger to share with visualization in the future * changing API for config_panel * fix the problem with icon * get rid of YConfig * color Picker refactorings * dont debounce UI * types fix Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../dimension_panel/dimension_panel.tsx | 56 ++----------- .../dimension_trigger/index.tsx | 71 ++++++++++++++++ .../public/xy_visualization/expression.tsx | 2 +- .../expression_reference_lines.tsx | 24 ++++-- .../reference_line_helpers.tsx | 11 ++- .../public/xy_visualization/state_helpers.ts | 14 +++- .../public/xy_visualization/to_expression.ts | 7 +- .../xy_visualization/visualization.test.ts | 29 ++++--- .../public/xy_visualization/visualization.tsx | 30 +++++-- .../visualization_helpers.tsx | 40 ++++++--- .../xy_config_panel/color_picker.tsx | 41 +++------- .../xy_config_panel/dimension_editor.tsx | 72 ++++++++-------- .../xy_config_panel/reference_line_panel.tsx | 37 ++++----- .../shared/line_style_settings.tsx | 16 ++-- .../shared/marker_decoration_settings.tsx | 24 +++--- .../xy_config_panel/xy_config_panel.test.tsx | 82 +++++++++++-------- 16 files changed, 325 insertions(+), 231 deletions(-) create mode 100644 x-pack/plugins/lens/public/shared_components/dimension_trigger/index.tsx diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index bf85c91b887237..84e84c3a79a9a6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -6,8 +6,6 @@ */ import React, { memo, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiText, EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { DatasourceDimensionTriggerProps, DatasourceDimensionEditorProps } from '../../types'; @@ -18,6 +16,7 @@ import { IndexPatternPrivateState } from '../types'; import { DimensionEditor } from './dimension_editor'; import { DateRange, layerTypes } from '../../../common'; import { getOperationSupportMatrix } from './operation_support'; +import { DimensionTrigger } from '../../shared_components/dimension_trigger'; export type IndexPatternDimensionTriggerProps = DatasourceDimensionTriggerProps & { @@ -63,55 +62,14 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens } const formattedLabel = wrapOnDot(uniqueLabel); - if (currentColumnHasErrors) { - let tooltipContent; - if (!hideTooltip) { - tooltipContent = invalidMessage ?? ( -

- {i18n.translate('xpack.lens.configure.invalidConfigTooltip', { - defaultMessage: 'Invalid configuration.', - })} -
- {i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', { - defaultMessage: 'Click for more details.', - })} -

- ); - } - - return ( - - - - - - - {selectedColumn.label} - - - - ); - } - return ( - - - - {formattedLabel} - - - + label={!currentColumnHasErrors ? formattedLabel : selectedColumn.label} + isInvalid={Boolean(currentColumnHasErrors)} + hideTooltip={hideTooltip} + invalidMessage={invalidMessage} + /> ); }; diff --git a/x-pack/plugins/lens/public/shared_components/dimension_trigger/index.tsx b/x-pack/plugins/lens/public/shared_components/dimension_trigger/index.tsx new file mode 100644 index 00000000000000..25d81424c58df9 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dimension_trigger/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiText, EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export const defaultDimensionTriggerTooltip = ( +

+ {i18n.translate('xpack.lens.configure.invalidConfigTooltip', { + defaultMessage: 'Invalid configuration.', + })} +
+ {i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', { + defaultMessage: 'Click for more details.', + })} +

+); + +export const DimensionTrigger = ({ + id, + label, + isInvalid, + hideTooltip, + invalidMessage = defaultDimensionTriggerTooltip, +}: { + label: string; + id: string; + isInvalid?: boolean; + hideTooltip?: boolean; + invalidMessage?: string | JSX.Element; +}) => { + if (isInvalid) { + return ( + + + + + + + {label} + + + + ); + } + + return ( + + + + {label} + + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 68dc8e26f320c5..3e300778b85b95 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -262,7 +262,7 @@ export function XYChart({ }); if (filteredLayers.length === 0) { - const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; + const icon: IconType = getIconForSeriesType(layers?.[0]?.seriesType || 'bar'); return ; } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx index d9a6a84bb5383e..2d22f6a6ed76ea 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx @@ -13,7 +13,7 @@ import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from ' import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { FieldFormat } from 'src/plugins/field_formats/common'; import { euiLightVars } from '@kbn/ui-theme'; -import type { ReferenceLineLayerArgs, YConfig } from '../../common/expressions'; +import type { IconPosition, ReferenceLineLayerArgs, YAxisMode } from '../../common/expressions'; import type { LensMultiTable } from '../../common/types'; import { hasIcon } from './xy_config_panel/shared/icon_select'; @@ -101,8 +101,8 @@ function mapVerticalToHorizontalPlacement(placement: Position) { // otherwise use the same axis // this function assume the chart is vertical function getBaseIconPlacement( - iconPosition: YConfig['iconPosition'], - axisMode: YConfig['axisMode'], + iconPosition: IconPosition | undefined, + axisMode: YAxisMode | undefined, axesMap: Record ) { if (iconPosition === 'auto') { @@ -157,23 +157,29 @@ function getMarkerBody(label: string | undefined, isHorizontal: boolean) { ); } +interface MarkerConfig { + axisMode?: YAxisMode; + icon?: string; + textVisibility?: boolean; +} + function getMarkerToShow( - yConfig: YConfig, + markerConfig: MarkerConfig, label: string | undefined, isHorizontal: boolean, hasReducedPadding: boolean ) { // show an icon if present - if (hasIcon(yConfig.icon)) { - return ; + if (hasIcon(markerConfig.icon)) { + return ; } // if there's some text, check whether to show it as marker, or just show some padding for the icon - if (yConfig.textVisibility) { + if (markerConfig.textVisibility) { if (hasReducedPadding) { return getMarkerBody( label, - (!isHorizontal && yConfig.axisMode === 'bottom') || - (isHorizontal && yConfig.axisMode !== 'bottom') + (!isHorizontal && markerConfig.axisMode === 'bottom') || + (isHorizontal && markerConfig.axisMode !== 'bottom') ); } return ; diff --git a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx index 05a81b15efabac..ac50a81da5423b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx @@ -8,7 +8,11 @@ import { groupBy, partition } from 'lodash'; import { i18n } from '@kbn/i18n'; import { layerTypes } from '../../common'; -import type { XYDataLayerConfig, XYLayerConfig, YConfig } from '../../common/expressions'; +import type { + XYDataLayerConfig, + XYReferenceLineLayerConfig, + YConfig, +} from '../../common/expressions'; import { Datatable } from '../../../../../src/plugins/expressions/public'; import type { AccessorConfig, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types'; import { groupAxesByType } from './axes_configuration'; @@ -19,6 +23,7 @@ import { getAxisName, getDataLayers, isNumericMetric, + isReferenceLayer, } from './visualization_helpers'; import { generateId } from '../id_generator'; import { LensIconChartBarReferenceLine } from '../assets/chart_bar_reference_line'; @@ -322,7 +327,7 @@ export const setReferenceDimension: Visualization['setDimension'] = ({ previousColumn, }) => { const foundLayer = prevState.layers.find((l) => l.layerId === layerId); - if (!foundLayer) { + if (!foundLayer || !isReferenceLayer(foundLayer)) { return prevState; } const newLayer = { ...foundLayer }; @@ -364,7 +369,7 @@ export const getReferenceConfiguration = ({ }: { state: XYState; frame: FramePublicAPI; - layer: XYLayerConfig; + layer: XYReferenceLineLayerConfig; sortedAccessors: string[]; mappedAccessors: AccessorConfig[]; }) => { diff --git a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts index f37ff5460f3149..dee78997401736 100644 --- a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts +++ b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts @@ -7,7 +7,14 @@ import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import type { FramePublicAPI, DatasourcePublicAPI } from '../types'; -import type { SeriesType, XYLayerConfig, YConfig, ValidLayer } from '../../common/expressions'; +import type { + SeriesType, + XYLayerConfig, + YConfig, + ValidLayer, + XYDataLayerConfig, + XYReferenceLineLayerConfig, +} from '../../common/expressions'; import { visualizationTypes } from './types'; import { getDataLayers, isDataLayer } from './visualization_helpers'; @@ -54,7 +61,10 @@ export const getSeriesColor = (layer: XYLayerConfig, accessor: string) => { ); }; -export const getColumnToLabelMap = (layer: XYLayerConfig, datasource: DatasourcePublicAPI) => { +export const getColumnToLabelMap = ( + layer: XYDataLayerConfig | XYReferenceLineLayerConfig, + datasource: DatasourcePublicAPI +) => { const columnToLabel: Record = {}; layer.accessors .concat(isDataLayer(layer) && layer.splitAccessor ? [layer.splitAccessor] : []) diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 8a79e05cb466bf..37457c61b26033 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -13,7 +13,7 @@ import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import type { ValidLayer, - XYLayerConfig, + XYDataLayerConfig, XYReferenceLineLayerConfig, YConfig, } from '../../common/expressions'; @@ -23,7 +23,10 @@ import { defaultReferenceLineColor } from './color_assignment'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; import { isDataLayer } from './visualization_helpers'; -export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: XYLayerConfig) => { +export const getSortedAccessors = ( + datasource: DatasourcePublicAPI, + layer: XYDataLayerConfig | XYReferenceLineLayerConfig +) => { const originalOrder = datasource .getTableSpec() .map(({ columnId }: { columnId: string }) => columnId) diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 5b430fd7fc579d..07e411b1993c95 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -9,7 +9,12 @@ import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; import { Operation, VisualizeEditorContext, Suggestion, OperationDescriptor } from '../types'; import type { State, XYSuggestion } from './types'; -import type { SeriesType, XYDataLayerConfig, XYLayerConfig } from '../../common/expressions'; +import type { + SeriesType, + XYDataLayerConfig, + XYLayerConfig, + XYReferenceLineLayerConfig, +} from '../../common/expressions'; import { layerTypes } from '../../common'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { LensIconChartBar } from '../assets/chart_bar'; @@ -422,7 +427,7 @@ describe('xy_visualization', () => { context, }); expect(state?.layers[0]).toHaveProperty('seriesType', 'area'); - expect(state?.layers[0].yConfig).toStrictEqual([ + expect((state?.layers[0] as XYDataLayerConfig).yConfig).toStrictEqual([ { axisMode: 'right', color: '#68BC00', @@ -1065,7 +1070,7 @@ describe('xy_visualization', () => { it('should support static value', () => { const state = getStateWithBaseReferenceLine(); state.layers[0].accessors = []; - state.layers[1].yConfig = undefined; + (state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined; expect( xyVisualization.getConfiguration({ state: getStateWithBaseReferenceLine(), @@ -1078,7 +1083,7 @@ describe('xy_visualization', () => { it('should return no referenceLine groups for a empty data layer', () => { const state = getStateWithBaseReferenceLine(); state.layers[0].accessors = []; - state.layers[1].yConfig = undefined; + (state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined; const options = xyVisualization.getConfiguration({ state, @@ -1102,8 +1107,8 @@ describe('xy_visualization', () => { it('should return a group for the vertical right axis', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].yConfig = [{ axisMode: 'right', forAccessor: 'a' }]; - state.layers[1].yConfig![0].axisMode = 'right'; + (state.layers[0] as XYDataLayerConfig).yConfig = [{ axisMode: 'right', forAccessor: 'a' }]; + (state.layers[1] as XYReferenceLineLayerConfig).yConfig![0].axisMode = 'right'; const options = xyVisualization.getConfiguration({ state, @@ -1119,7 +1124,7 @@ describe('xy_visualization', () => { const state = getStateWithBaseReferenceLine(); (state.layers[0] as XYDataLayerConfig).xAccessor = 'b'; (state.layers[0] as XYDataLayerConfig).accessors = []; - state.layers[1].yConfig = []; // empty the configuration + (state.layers[1] as XYReferenceLineLayerConfig).yConfig = []; // empty the configuration // set the xAccessor as date_histogram frame.datasourceLayers.referenceLine.getOperationForColumnId = jest.fn((accessor) => { if (accessor === 'b') { @@ -1148,7 +1153,7 @@ describe('xy_visualization', () => { const state = getStateWithBaseReferenceLine(); (state.layers[0] as XYDataLayerConfig).xAccessor = 'b'; (state.layers[0] as XYDataLayerConfig).accessors = []; - state.layers[1].yConfig![0].axisMode = 'bottom'; + (state.layers[1] as XYReferenceLineLayerConfig).yConfig![0].axisMode = 'bottom'; // set the xAccessor as date_histogram frame.datasourceLayers.referenceLine.getOperationForColumnId = jest.fn((accessor) => { if (accessor === 'b') { @@ -1187,7 +1192,7 @@ describe('xy_visualization', () => { { axisMode: 'right', forAccessor: 'b' }, { axisMode: 'left', forAccessor: 'a' }, ]; - state.layers[1].yConfig = [ + (state.layers[1] as XYReferenceLineLayerConfig).yConfig = [ { forAccessor: 'c', axisMode: 'bottom' }, { forAccessor: 'b', axisMode: 'right' }, { forAccessor: 'a', axisMode: 'left' }, @@ -1222,7 +1227,7 @@ describe('xy_visualization', () => { const state = getStateWithBaseReferenceLine(); (state.layers[0] as XYDataLayerConfig).xAccessor = 'b'; (state.layers[0] as XYDataLayerConfig).accessors = []; - state.layers[1].yConfig = []; // empty the configuration + (state.layers[1] as XYReferenceLineLayerConfig).yConfig = []; // empty the configuration // set the xAccessor as top values frame.datasourceLayers.referenceLine.getOperationForColumnId = jest.fn((accessor) => { if (accessor === 'b') { @@ -1251,7 +1256,7 @@ describe('xy_visualization', () => { const state = getStateWithBaseReferenceLine(); (state.layers[0] as XYDataLayerConfig).xAccessor = 'b'; (state.layers[0] as XYDataLayerConfig).accessors = []; - state.layers[1].yConfig![0].axisMode = 'bottom'; + (state.layers[1] as XYReferenceLineLayerConfig).yConfig![0].axisMode = 'bottom'; // set the xAccessor as date_histogram frame.datasourceLayers.referenceLine.getOperationForColumnId = jest.fn((accessor) => { if (accessor === 'b') { @@ -1317,7 +1322,7 @@ describe('xy_visualization', () => { const state = getStateWithBaseReferenceLine(); (state.layers[0] as XYDataLayerConfig).accessors = ['yAccessorId', 'yAccessorId2']; - state.layers[1].yConfig = []; // empty the configuration + (state.layers[1] as XYReferenceLineLayerConfig).yConfig = []; // empty the configuration const options = xyVisualization.getConfiguration({ state, diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 69349b1de34453..c9951c24f8a47e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -53,6 +53,7 @@ import { } from './visualization_helpers'; import { groupAxesByType } from './axes_configuration'; import { XYState } from '..'; +import { ReferenceLinePanel } from './xy_config_panel/reference_line_panel'; export const getXyVisualization = ({ paletteService, @@ -94,7 +95,11 @@ export const getXyVisualization = ({ ...state, layers: [ ...state.layers, - newLayerState(firstUsedSeriesType || state.preferredSeriesType, layerId, layerType), + newLayerState({ + seriesType: firstUsedSeriesType || state.preferredSeriesType, + layerId, + layerType, + }), ], }; }, @@ -103,7 +108,9 @@ export const getXyVisualization = ({ return { ...state, layers: state.layers.map((l) => - l.layerId !== layerId ? l : newLayerState(state.preferredSeriesType, layerId) + l.layerId !== layerId + ? l + : newLayerState({ seriesType: state.preferredSeriesType, layerId }) ), }; }, @@ -435,15 +442,20 @@ export const getXyVisualization = ({ }, renderDimensionEditor(domElement, props) { + const allProps = { + ...props, + formatFactory: fieldFormats.deserialize, + paletteService, + }; + const layer = props.state.layers.find((l) => l.layerId === props.layerId)!; + const dimensionEditor = isReferenceLayer(layer) ? ( + + ) : ( + + ); render( - - - + {dimensionEditor} , domElement ); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx index 69df0d80300b2b..7446c2a06119c4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx @@ -136,8 +136,9 @@ export const getDataLayers = (layers: XYLayerConfig[]) => export const getFirstDataLayer = (layers: XYLayerConfig[]) => (layers || []).find((layer): layer is XYDataLayerConfig => isDataLayer(layer)); -export const isReferenceLayer = (layer: XYLayerConfig): layer is XYReferenceLineLayerConfig => - layer.layerType === layerTypes.REFERENCELINE; +export const isReferenceLayer = ( + layer: Pick +): layer is XYReferenceLineLayerConfig => layer.layerType === layerTypes.REFERENCELINE; export const getReferenceLayers = (layers: XYLayerConfig[]) => (layers || []).filter((layer): layer is XYReferenceLineLayerConfig => isReferenceLayer(layer)); @@ -236,17 +237,36 @@ export function getMessageIdsForDimension( return { shortMessage: '', longMessage: '' }; } -export function newLayerState( - seriesType: SeriesType, - layerId: string, - layerType: LayerType = layerTypes.DATA -): XYLayerConfig { - return { +const newLayerFn = { + [layerTypes.DATA]: ({ layerId, seriesType, + }: { + layerId: string; + seriesType: SeriesType; + }): XYDataLayerConfig => ({ + layerId, + layerType: layerTypes.DATA, accessors: [], - layerType, - }; + seriesType, + }), + [layerTypes.REFERENCELINE]: ({ layerId }: { layerId: string }): XYReferenceLineLayerConfig => ({ + layerId, + layerType: layerTypes.REFERENCELINE, + accessors: [], + }), +}; + +export function newLayerState({ + layerId, + layerType = layerTypes.DATA, + seriesType, +}: { + layerId: string; + layerType?: LayerType; + seriesType: SeriesType; +}) { + return newLayerFn[layerType]({ layerId, seriesType }); } export function getLayersByType(state: State, byType?: string) { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx index 5db92e2dbb568b..8aa2aaf16ae5fa 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx @@ -7,7 +7,6 @@ import React, { useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { debounce } from 'lodash'; import { EuiFormRow, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { VisualizationDimensionEditorProps } from '../../types'; @@ -20,9 +19,8 @@ import { getColorAssignments, } from '../color_assignment'; import { getSortedAccessors } from '../to_expression'; -import { updateLayer } from '.'; import { TooltipWrapper } from '../../shared_components'; -import { isDataLayer, isReferenceLayer } from '../visualization_helpers'; +import { isReferenceLayer } from '../visualization_helpers'; const tooltipContent = { auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { @@ -39,7 +37,6 @@ const tooltipContent = { export const ColorPicker = ({ state, - setState, layerId, accessor, frame, @@ -47,11 +44,15 @@ export const ColorPicker = ({ paletteService, label, disableHelpTooltip, + disabled, + setConfig, }: VisualizationDimensionEditorProps & { formatFactory: FormatFactory; paletteService: PaletteRegistry; label?: string; disableHelpTooltip?: boolean; + disabled?: boolean; + setConfig: (config: { color?: string }) => void; }) => { const index = state.layers.findIndex((l) => l.layerId === layerId); const layer = state.layers[index]; @@ -63,8 +64,10 @@ export const ColorPicker = ({ return defaultReferenceLineColor; } - const datasource = frame.datasourceLayers[layer.layerId]; - const sortedAccessors: string[] = getSortedAccessors(datasource, layer); + const sortedAccessors: string[] = getSortedAccessors( + frame.datasourceLayers[layer.layerId], + layer + ); const colorAssignments = getColorAssignments( state.layers, @@ -86,36 +89,14 @@ export const ColorPicker = ({ const [color, setColor] = useState(currentColor); - const disabled = Boolean(isDataLayer(layer) && layer.splitAccessor); const handleColor: EuiColorPickerProps['onChange'] = (text, output) => { setColor(text); if (output.isValid || text === '') { - updateColorInState(text, output); + const newColor = text === '' ? undefined : output.hex; + setConfig({ color: newColor }); } }; - const updateColorInState: EuiColorPickerProps['onChange'] = useMemo( - () => - debounce((text, output) => { - const newYConfigs = [...(layer.yConfig || [])]; - const existingIndex = newYConfigs.findIndex((yConfig) => yConfig.forAccessor === accessor); - if (existingIndex !== -1) { - if (text === '') { - newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: undefined }; - } else { - newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: output.hex }; - } - } else { - newYConfigs.push({ - forAccessor: accessor, - color: output.hex, - }); - } - setState(updateLayer(state, { ...layer, yConfig: newYConfigs }, index)); - }, 256), - [state, setState, layer, accessor, index] - ); - const inputLabel = label ?? i18n.translate('xpack.lens.xyChart.seriesColor.label', { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/dimension_editor.tsx index dce32d1d6b116a..1618bd4d0e540b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/dimension_editor.tsx @@ -5,19 +5,17 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonGroup, EuiFormRow, htmlIdGenerator } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { VisualizationDimensionEditorProps } from '../../types'; -import { State } from '../types'; +import { State, XYState } from '../types'; import { FormatFactory } from '../../../common'; -import { YAxisMode } from '../../../common/expressions'; +import { XYDataLayerConfig, YAxisMode, YConfig } from '../../../common/expressions'; import { isHorizontalChart } from '../state_helpers'; import { ColorPicker } from './color_picker'; -import { ReferenceLinePanel } from './reference_line_panel'; -import { PalettePicker } from '../../shared_components'; -import { isReferenceLayer } from '../visualization_helpers'; +import { PalettePicker, useDebouncedValue } from '../../shared_components'; type UnwrapArray = T extends Array ? P : T; @@ -45,25 +43,48 @@ export function DimensionEditor( ) { const { state, setState, layerId, accessor } = props; const index = state.layers.findIndex((l) => l.layerId === layerId); - const layer = state.layers[index]; - if (isReferenceLayer(layer)) { - return ; - } + const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ + value: props.state, + onChange: props.setState, + }); + + const localLayer = localState.layers.find((l) => l.layerId === layerId) as XYDataLayerConfig; + const localYConfig = localLayer?.yConfig?.find( + (yAxisConfig) => yAxisConfig.forAccessor === accessor + ); + const axisMode = localYConfig?.axisMode || 'auto'; - const axisMode = - (layer.yConfig && - layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) || - 'auto'; + const setConfig = useCallback( + (yConfig: Partial | undefined) => { + if (yConfig == null) { + return; + } + const newYConfigs = [...(localLayer.yConfig || [])]; + const existingIndex = newYConfigs.findIndex( + (yAxisConfig) => yAxisConfig.forAccessor === accessor + ); + if (existingIndex !== -1) { + newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], ...yConfig }; + } else { + newYConfigs.push({ + forAccessor: accessor, + ...yConfig, + }); + } + setLocalState(updateLayer(localState, { ...localLayer, yConfig: newYConfigs }, index)); + }, + [accessor, index, localState, localLayer, setLocalState] + ); if (props.groupId === 'breakdown') { return ( <> { - setState(updateLayer(state, { ...layer, palette: newPalette }, index)); + setState(updateLayer(localState, { ...localLayer, palette: newPalette }, index)); }} /> @@ -74,7 +95,7 @@ export function DimensionEditor( return ( <> - + { const newMode = id.replace(idPrefix, '') as YAxisMode; - const newYAxisConfigs = [...(layer.yConfig || [])]; - const existingIndex = newYAxisConfigs.findIndex( - (yAxisConfig) => yAxisConfig.forAccessor === accessor - ); - if (existingIndex !== -1) { - newYAxisConfigs[existingIndex] = { - ...newYAxisConfigs[existingIndex], - axisMode: newMode, - }; - } else { - newYAxisConfigs.push({ - forAccessor: accessor, - axisMode: newMode, - }); - } - setState(updateLayer(state, { ...layer, yConfig: newYAxisConfigs }, index)); + setConfig({ axisMode: newMode }); }} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx index 02cd9d45a41908..f00d60b0dc814c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx @@ -13,7 +13,7 @@ import type { VisualizationDimensionEditorProps } from '../../types'; import { State, XYState } from '../types'; import { FormatFactory } from '../../../common'; import { YConfig } from '../../../common/expressions'; -import { FillStyle } from '../../../common/expressions/xy_chart'; +import { FillStyle, XYReferenceLineLayerConfig } from '../../../common/expressions/xy_chart'; import { ColorPicker } from './color_picker'; import { updateLayer } from '.'; @@ -32,21 +32,26 @@ export const ReferenceLinePanel = ( const { state, setState, layerId, accessor } = props; const isHorizontal = isHorizontalChart(state.layers); + const index = state.layers.findIndex((l) => l.layerId === layerId); const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ value: state, onChange: setState, }); - const index = localState.layers.findIndex((l) => l.layerId === layerId); - const layer = localState.layers[index]; + const localLayer = localState.layers.find( + (l) => l.layerId === layerId + ) as XYReferenceLineLayerConfig; + const localConfig = localLayer?.yConfig?.find( + (yAxisConfig) => yAxisConfig.forAccessor === accessor + ); const setConfig = useCallback( (yConfig: Partial | undefined) => { if (yConfig == null) { return; } - const newYConfigs = [...(layer.yConfig || [])]; + const newYConfigs = [...(localLayer.yConfig || [])]; const existingIndex = newYConfigs.findIndex( (yAxisConfig) => yAxisConfig.forAccessor === accessor ); @@ -58,35 +63,27 @@ export const ReferenceLinePanel = ( ...yConfig, }); } - setLocalState(updateLayer(localState, { ...layer, yConfig: newYConfigs }, index)); + setLocalState(updateLayer(localState, { ...localLayer, yConfig: newYConfigs }, index)); }, - [accessor, index, localState, layer, setLocalState] + [accessor, index, localState, localLayer, setLocalState] ); - const currentConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); - return ( <> - + | undefined) => void; - accessor: string; isHorizontal: boolean; }) => { return ( @@ -170,7 +165,7 @@ export const FillSetting = ({ idSelected={`${idPrefix}${currentConfig?.fill || 'none'}`} onChange={(id) => { const newMode = id.replace(idPrefix, '') as FillStyle; - setConfig({ forAccessor: accessor, fill: newMode }); + setConfig({ fill: newMode }); }} />
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx index e10156c2c31c19..db01a027d8fec5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx @@ -14,19 +14,21 @@ import { EuiFlexItem, EuiFormRow, } from '@elastic/eui'; -import { YConfig } from '../../../../common/expressions'; import { LineStyle } from '../../../../common/expressions/xy_chart'; import { idPrefix } from '../dimension_editor'; +interface LineStyleConfig { + lineStyle?: LineStyle; + lineWidth?: number; +} + export const LineStyleSettings = ({ currentConfig, setConfig, - accessor, isHorizontal, }: { - currentConfig?: Pick; - setConfig: (yConfig: Partial | undefined) => void; - accessor: string; + currentConfig?: LineStyleConfig; + setConfig: (config: LineStyleConfig) => void; isHorizontal: boolean; }) => { return ( @@ -43,7 +45,7 @@ export const LineStyleSettings = ({ { - setConfig({ forAccessor: accessor, lineWidth: value }); + setConfig({ lineWidth: value }); }} /> @@ -85,7 +87,7 @@ export const LineStyleSettings = ({ idSelected={`${idPrefix}${currentConfig?.lineStyle || 'solid'}`} onChange={(id) => { const newMode = id.replace(idPrefix, '') as LineStyle; - setConfig({ forAccessor: accessor, lineStyle: newMode }); + setConfig({ lineStyle: newMode }); }} isIconOnly /> diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/marker_decoration_settings.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/marker_decoration_settings.tsx index 9579cbd1f0c0b3..b5400feb5d3b68 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/marker_decoration_settings.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/marker_decoration_settings.tsx @@ -8,8 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonGroup, EuiFormRow } from '@elastic/eui'; -import { YConfig } from '../../../../common/expressions'; -import { IconPosition } from '../../../../common/expressions/xy_chart'; +import { IconPosition, YAxisMode } from '../../../../common/expressions/xy_chart'; import { TooltipWrapper } from '../../../shared_components'; import { hasIcon, IconSelect } from './icon_select'; @@ -17,7 +16,7 @@ import { idPrefix } from '../dimension_editor'; interface LabelConfigurationOptions { isHorizontal: boolean; - axisMode: YConfig['axisMode']; + axisMode?: YAxisMode; } function getIconPositionOptions({ isHorizontal, axisMode }: LabelConfigurationOptions) { @@ -71,15 +70,20 @@ function getIconPositionOptions({ isHorizontal, axisMode }: LabelConfigurationOp ]; } +interface MarkerDecorationConfig { + axisMode?: YAxisMode; + icon?: string; + iconPosition?: IconPosition; + textVisibility?: boolean; +} + export const MarkerDecorationSettings = ({ currentConfig, setConfig, - accessor, isHorizontal, }: { - currentConfig?: Pick; - setConfig: (yConfig: Partial | undefined) => void; - accessor: string; + currentConfig?: MarkerDecorationConfig; + setConfig: (config: MarkerDecorationConfig) => void; isHorizontal: boolean; }) => { return ( @@ -116,7 +120,7 @@ export const MarkerDecorationSettings = ({ ]} idSelected={`${idPrefix}${Boolean(currentConfig?.textVisibility) ? 'name' : 'none'}`} onChange={(id) => { - setConfig({ forAccessor: accessor, textVisibility: id === `${idPrefix}name` }); + setConfig({ textVisibility: id === `${idPrefix}name` }); }} isFullWidth /> @@ -131,7 +135,7 @@ export const MarkerDecorationSettings = ({ { - setConfig({ forAccessor: accessor, icon: newIcon }); + setConfig({ icon: newIcon }); }} />
@@ -170,7 +174,7 @@ export const MarkerDecorationSettings = ({ idSelected={`${idPrefix}${currentConfig?.iconPosition || 'auto'}`} onChange={(id) => { const newMode = id.replace(idPrefix, '') as IconPosition; - setConfig({ forAccessor: accessor, iconPosition: newMode }); + setConfig({ iconPosition: newMode }); }} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx index 1e80c6e843ba27..a73d14ad155730 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx @@ -12,7 +12,7 @@ import { XyToolbar } from '.'; import { DimensionEditor } from './dimension_editor'; import { AxisSettingsPopover } from './axis_settings_popover'; import { FramePublicAPI } from '../../types'; -import { State } from '../types'; +import { State, XYState } from '../types'; import { Position } from '@elastic/charts'; import { createMockFramePublicAPI, createMockDatasource } from '../../mocks'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; @@ -64,7 +64,12 @@ describe('XY Config panels', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], yConfig: [{ axisMode: 'right', forAccessor: 'bar' }] }], + layers: [ + { + ...state.layers[0], + yConfig: [{ axisMode: 'right', forAccessor: 'bar' }], + } as XYDataLayerConfig, + ], }} /> ); @@ -80,7 +85,12 @@ describe('XY Config panels', () => { setState={jest.fn()} state={{ ...state, - layers: [{ ...state.layers[0], yConfig: [{ axisMode: 'right', forAccessor: 'foo' }] }], + layers: [ + { + ...state.layers[0], + yConfig: [{ axisMode: 'right', forAccessor: 'foo' }], + } as XYDataLayerConfig, + ], }} /> ); @@ -100,7 +110,12 @@ describe('XY Config panels', () => { state={{ ...state, hideEndzones: true, - layers: [{ ...state.layers[0], yConfig: [{ axisMode: 'right', forAccessor: 'foo' }] }], + layers: [ + { + ...state.layers[0], + yConfig: [{ axisMode: 'right', forAccessor: 'foo' }], + } as XYDataLayerConfig, + ], }} /> ); @@ -249,7 +264,19 @@ describe('XY Config panels', () => { }); test('sets the color of a dimension to the color from palette service if not set explicitly', () => { - const state = testState(); + const state = { + ...testState(), + layers: [ + { + seriesType: 'bar', + layerType: layerTypes.DATA, + layerId: 'first', + splitAccessor: undefined, + xAccessor: 'foo', + accessors: ['bar'], + }, + ], + } as XYState; const component = mount( { setState={jest.fn()} accessor="bar" groupId="left" - state={{ - ...state, - layers: [ - { - seriesType: 'bar', - layerType: layerTypes.DATA, - layerId: 'first', - splitAccessor: undefined, - xAccessor: 'foo', - accessors: ['bar'], - }, - ], - }} + state={state} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} panelRef={React.createRef()} @@ -289,7 +304,21 @@ describe('XY Config panels', () => { }); test('uses the overwrite color if set', () => { - const state = testState(); + const state = { + ...testState(), + layers: [ + { + seriesType: 'bar', + layerType: layerTypes.DATA, + layerId: 'first', + splitAccessor: undefined, + xAccessor: 'foo', + accessors: ['bar'], + yConfig: [{ forAccessor: 'bar', color: 'red' }], + }, + ], + } as XYState; + const component = mount( { setState={jest.fn()} accessor="bar" groupId="left" - state={{ - ...state, - layers: [ - { - seriesType: 'bar', - layerType: layerTypes.DATA, - layerId: 'first', - splitAccessor: undefined, - xAccessor: 'foo', - accessors: ['bar'], - yConfig: [{ forAccessor: 'bar', color: 'red' }], - }, - ], - }} + state={state} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} panelRef={React.createRef()} From 51af085e9b403d012c2b60e762be200f98cb85a5 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 16 Mar 2022 09:55:45 +0100 Subject: [PATCH 21/39] Get rid of `axios` dependency in the Upgrade Assistant tests. (#127784) --- .../client_integration/app/app.helpers.tsx | 11 +- .../app/cluster_upgrade.test.tsx | 19 +- .../es_deprecation_logs.helpers.ts | 6 +- .../es_deprecation_logs.test.tsx | 47 +-- .../default_deprecation_flyout.test.ts | 18 +- .../es_deprecations/deprecations_list.test.ts | 48 +-- .../es_deprecations/error_handling.test.ts | 18 +- .../es_deprecations.helpers.ts | 6 +- .../index_settings_deprecation_flyout.test.ts | 51 +-- .../ml_snapshots_deprecation_flyout.test.ts | 71 +++-- .../es_deprecations/mocked_responses.ts | 2 +- .../reindex_deprecation_flyout.test.ts | 50 +-- .../helpers/http_requests.ts | 297 +++++++----------- .../helpers/setup_environment.tsx | 17 +- .../deprecation_details_flyout.test.ts | 8 +- .../deprecations_table.test.ts | 12 +- .../deprecations_table/error_handling.test.ts | 11 +- .../kibana_deprecations.helpers.ts | 4 +- .../overview/backup_step/backup_step.test.tsx | 26 +- .../elasticsearch_deprecation_issues.test.tsx | 20 +- .../fix_issues_step/fix_issues_step.test.tsx | 14 +- .../kibana_deprecation_issues.test.tsx | 14 +- .../overview/logs_step/logs_step.test.tsx | 23 +- .../migrate_system_indices/flyout.test.ts | 14 +- .../migrate_system_indices.test.tsx | 26 +- .../step_completion.test.ts | 16 +- .../overview/overview.helpers.ts | 9 +- .../overview/overview.test.tsx | 8 +- .../upgrade_step/upgrade_step.test.tsx | 43 +-- 29 files changed, 421 insertions(+), 488 deletions(-) diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/app.helpers.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/app.helpers.tsx index 25e38b4111e50d..ba09db95ee8839 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/app.helpers.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/app.helpers.tsx @@ -8,6 +8,7 @@ import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { App } from '../../../public/application/app'; import { WithAppDependencies } from '../helpers'; @@ -39,8 +40,14 @@ const createActions = (testBed: TestBed) => { }; }; -export const setupAppPage = async (overrides?: Record): Promise => { - const initTestBed = registerTestBed(WithAppDependencies(App, overrides), testBedConfig); +export const setupAppPage = async ( + httpSetup: HttpSetup, + overrides?: Record +): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(App, httpSetup, overrides), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/cluster_upgrade.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/cluster_upgrade.test.tsx index 7276d005844c26..0ef228431592ba 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/cluster_upgrade.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/cluster_upgrade.test.tsx @@ -12,20 +12,17 @@ import { AppTestBed, setupAppPage } from './app.helpers'; describe('Cluster upgrade', () => { let testBed: AppTestBed; - let server: ReturnType['server']; let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; - - beforeEach(() => { - ({ server, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterEach(() => { - server.restore(); + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); describe('when user is still preparing for upgrade', () => { beforeEach(async () => { - testBed = await setupAppPage(); + testBed = await setupAppPage(httpSetup); }); test('renders overview', () => { @@ -52,7 +49,7 @@ describe('Cluster upgrade', () => { }); await act(async () => { - testBed = await setupAppPage(); + testBed = await setupAppPage(httpSetup); }); }); @@ -76,7 +73,7 @@ describe('Cluster upgrade', () => { }); await act(async () => { - testBed = await setupAppPage(); + testBed = await setupAppPage(httpSetup); }); }); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecation_logs/es_deprecation_logs.helpers.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecation_logs/es_deprecation_logs.helpers.ts index 31bbcd01a7320d..813a269bf5a974 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecation_logs/es_deprecation_logs.helpers.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecation_logs/es_deprecation_logs.helpers.ts @@ -7,7 +7,8 @@ import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; -import { EsDeprecationLogs } from '../../../public/application/components/es_deprecation_logs'; +import { HttpSetup } from 'src/core/public'; +import { EsDeprecationLogs } from '../../../public/application/components'; import { WithAppDependencies } from '../helpers'; const testBedConfig: AsyncTestBedConfig = { @@ -65,10 +66,11 @@ const createActions = (testBed: TestBed) => { }; export const setupESDeprecationLogsPage = async ( + httpSetup: HttpSetup, overrides?: Record ): Promise => { const initTestBed = registerTestBed( - WithAppDependencies(EsDeprecationLogs, overrides), + WithAppDependencies(EsDeprecationLogs, httpSetup, overrides), testBedConfig ); const testBed = await initTestBed(); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecation_logs/es_deprecation_logs.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecation_logs/es_deprecation_logs.test.tsx index 679a9175f5c501..f4b07c6f2f5ad3 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecation_logs/es_deprecation_logs.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecation_logs/es_deprecation_logs.test.tsx @@ -41,18 +41,18 @@ const getLoggingResponse = (toggle: boolean): DeprecationLoggingStatus => ({ describe('ES deprecation logs', () => { let testBed: EsDeprecationLogsTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; + httpRequestsMockHelpers.setLoadDeprecationLoggingResponse(getLoggingResponse(true)); - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); testBed.component.update(); }); - afterAll(() => { - server.restore(); - }); - describe('Documentation link', () => { test('Has a link for migration info api docs in page header', () => { const { exists } = testBed; @@ -71,8 +71,11 @@ describe('ES deprecation logs', () => { await actions.clickDeprecationToggle(); - const latestRequest = server.requests[server.requests.length - 1]; - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ isEnabled: false }); + expect(httpSetup.put).toHaveBeenLastCalledWith( + `/api/upgrade_assistant/deprecation_logging`, + expect.objectContaining({ body: JSON.stringify({ isEnabled: false }) }) + ); + expect(find('deprecationLoggingToggle').props()['aria-checked']).toBe(false); }); @@ -83,7 +86,7 @@ describe('ES deprecation logs', () => { }); await act(async () => { - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); }); const { exists, component } = testBed; @@ -119,7 +122,7 @@ describe('ES deprecation logs', () => { httpRequestsMockHelpers.setLoadDeprecationLoggingResponse(undefined, error); await act(async () => { - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); }); const { component, exists } = testBed; @@ -136,7 +139,7 @@ describe('ES deprecation logs', () => { }); await act(async () => { - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); }); const { exists, component } = testBed; @@ -156,7 +159,7 @@ describe('ES deprecation logs', () => { test('Has a link to see logs in observability app', async () => { await act(async () => { - testBed = await setupESDeprecationLogsPage({ + testBed = await setupESDeprecationLogsPage(httpSetup, { http: { basePath: { prepend: (url: string) => url, @@ -194,7 +197,7 @@ describe('ES deprecation logs', () => { test('Has a link to see logs in discover app', async () => { await act(async () => { - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); }); const { exists, component, find } = testBed; @@ -233,7 +236,7 @@ describe('ES deprecation logs', () => { }); await act(async () => { - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); }); const { find, exists, component } = testBed; @@ -250,7 +253,7 @@ describe('ES deprecation logs', () => { }); await act(async () => { - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); }); const { find, exists, component } = testBed; @@ -271,7 +274,7 @@ describe('ES deprecation logs', () => { httpRequestsMockHelpers.setLoadDeprecationLogsCountResponse(undefined, error); await act(async () => { - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); }); const { exists, actions, component } = testBed; @@ -295,7 +298,7 @@ describe('ES deprecation logs', () => { }); await act(async () => { - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); }); const { exists, actions, component } = testBed; @@ -327,7 +330,7 @@ describe('ES deprecation logs', () => { const addDanger = jest.fn(); await act(async () => { - testBed = await setupESDeprecationLogsPage({ + testBed = await setupESDeprecationLogsPage(httpSetup, { services: { core: { notifications: { @@ -365,7 +368,7 @@ describe('ES deprecation logs', () => { count: 0, }); - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); }); afterEach(() => { @@ -401,7 +404,7 @@ describe('ES deprecation logs', () => { test('It shows copy with compatibility api header advice', async () => { await act(async () => { - testBed = await setupESDeprecationLogsPage(); + testBed = await setupESDeprecationLogsPage(httpSetup); }); const { exists, component } = testBed; @@ -425,7 +428,7 @@ describe('ES deprecation logs', () => { test(`doesn't show analyze and resolve logs if it doesn't have the right privileges`, async () => { await act(async () => { - testBed = await setupESDeprecationLogsPage({ + testBed = await setupESDeprecationLogsPage(httpSetup, { privileges: { hasAllPrivileges: false, missingPrivileges: { diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/default_deprecation_flyout.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/default_deprecation_flyout.test.ts index 5566ec1d17e2b0..f728e6685817ae 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/default_deprecation_flyout.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/default_deprecation_flyout.test.ts @@ -13,13 +13,13 @@ import { esDeprecationsMockResponse, MOCK_SNAPSHOT_ID, MOCK_JOB_ID } from './moc describe('Default deprecation flyout', () => { let testBed: ElasticsearchTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); - + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(esDeprecationsMockResponse); httpRequestsMockHelpers.setUpgradeMlSnapshotStatusResponse({ nodeId: 'my_node', @@ -27,7 +27,7 @@ describe('Default deprecation flyout', () => { jobId: MOCK_JOB_ID, status: 'idle', }); - httpRequestsMockHelpers.setReindexStatusResponse({ + httpRequestsMockHelpers.setReindexStatusResponse('reindex_index', { reindexOp: null, warnings: [], hasRequiredPrivileges: true, @@ -39,7 +39,9 @@ describe('Default deprecation flyout', () => { }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { + isReadOnlyMode: false, + }); }); testBed.component.update(); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/deprecations_list.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/deprecations_list.test.ts index ca393e165e3357..457c0c4ec2be5a 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/deprecations_list.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/deprecations_list.test.ts @@ -20,13 +20,13 @@ import { describe('ES deprecations table', () => { let testBed: ElasticsearchTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); - + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(esDeprecationsMockResponse); httpRequestsMockHelpers.setUpgradeMlSnapshotStatusResponse({ nodeId: 'my_node', @@ -34,7 +34,7 @@ describe('ES deprecations table', () => { jobId: MOCK_JOB_ID, status: 'idle', }); - httpRequestsMockHelpers.setReindexStatusResponse({ + httpRequestsMockHelpers.setReindexStatusResponse('reindex_index', { reindexOp: null, warnings: [], hasRequiredPrivileges: true, @@ -47,7 +47,7 @@ describe('ES deprecations table', () => { httpRequestsMockHelpers.setLoadRemoteClustersResponse([]); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); testBed.component.update(); @@ -66,7 +66,6 @@ describe('ES deprecations table', () => { it('refreshes deprecation data', async () => { const { actions } = testBed; - const totalRequests = server.requests.length; await actions.table.clickRefreshButton(); @@ -74,21 +73,24 @@ describe('ES deprecations table', () => { const reindexDeprecation = esDeprecationsMockResponse.deprecations[3]; // Since upgradeStatusMockResponse includes ML and reindex actions (which require fetching status), there will be 4 requests made - expect(server.requests.length).toBe(totalRequests + 4); - expect(server.requests[server.requests.length - 4].url).toBe( - `${API_BASE_PATH}/es_deprecations` + expect(httpSetup.get).toHaveBeenCalledWith( + `${API_BASE_PATH}/es_deprecations`, + expect.anything() ); - expect(server.requests[server.requests.length - 3].url).toBe( + expect(httpSetup.get).toHaveBeenCalledWith( `${API_BASE_PATH}/ml_snapshots/${(mlDeprecation.correctiveAction as MlAction).jobId}/${ (mlDeprecation.correctiveAction as MlAction).snapshotId - }` + }`, + expect.anything() ); - expect(server.requests[server.requests.length - 2].url).toBe( - `${API_BASE_PATH}/reindex/${reindexDeprecation.index}` + expect(httpSetup.get).toHaveBeenCalledWith( + `${API_BASE_PATH}/reindex/${reindexDeprecation.index}`, + expect.anything() ); - expect(server.requests[server.requests.length - 1].url).toBe( - `${API_BASE_PATH}/ml_upgrade_mode` + expect(httpSetup.get).toHaveBeenCalledWith( + `${API_BASE_PATH}/ml_upgrade_mode`, + expect.anything() ); }); @@ -111,7 +113,9 @@ describe('ES deprecations table', () => { httpRequestsMockHelpers.setLoadRemoteClustersResponse(['test_remote_cluster']); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { + isReadOnlyMode: false, + }); }); testBed.component.update(); @@ -217,7 +221,9 @@ describe('ES deprecations table', () => { }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { + isReadOnlyMode: false, + }); }); testBed.component.update(); @@ -299,7 +305,7 @@ describe('ES deprecations table', () => { httpRequestsMockHelpers.setLoadEsDeprecationsResponse(noDeprecationsResponse); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); testBed.component.update(); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/error_handling.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/error_handling.test.ts index 2f0c8f0597ec3c..02e61fdaaadd0c 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/error_handling.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/error_handling.test.ts @@ -12,10 +12,12 @@ import { ElasticsearchTestBed, setupElasticsearchPage } from './es_deprecations. describe('Error handling', () => { let testBed: ElasticsearchTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); it('handles 403', async () => { @@ -28,7 +30,7 @@ describe('Error handling', () => { httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); const { component, find } = testBed; @@ -53,7 +55,7 @@ describe('Error handling', () => { httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); const { component, find } = testBed; @@ -76,7 +78,7 @@ describe('Error handling', () => { httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); const { component, find } = testBed; @@ -96,7 +98,7 @@ describe('Error handling', () => { httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); const { component, find } = testBed; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/es_deprecations.helpers.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/es_deprecations.helpers.ts index 08d53d1eaca7ec..02fe72883c34f9 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/es_deprecations.helpers.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/es_deprecations.helpers.ts @@ -7,7 +7,8 @@ import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; -import { EsDeprecations } from '../../../public/application/components/es_deprecations'; +import { HttpSetup } from 'src/core/public'; +import { EsDeprecations } from '../../../public/application/components'; import { WithAppDependencies } from '../helpers'; const testBedConfig: AsyncTestBedConfig = { @@ -146,10 +147,11 @@ const createActions = (testBed: TestBed) => { }; export const setupElasticsearchPage = async ( + httpSetup: HttpSetup, overrides?: Record ): Promise => { const initTestBed = registerTestBed( - WithAppDependencies(EsDeprecations, overrides), + WithAppDependencies(EsDeprecations, httpSetup, overrides), testBedConfig ); const testBed = await initTestBed(); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/index_settings_deprecation_flyout.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/index_settings_deprecation_flyout.test.ts index f032c34040bfe5..20b7bed032f765 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/index_settings_deprecation_flyout.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/index_settings_deprecation_flyout.test.ts @@ -9,18 +9,23 @@ import { act } from 'react-dom/test-utils'; import { setupEnvironment } from '../helpers'; import { ElasticsearchTestBed, setupElasticsearchPage } from './es_deprecations.helpers'; -import { esDeprecationsMockResponse, MOCK_SNAPSHOT_ID, MOCK_JOB_ID } from './mocked_responses'; +import { + esDeprecationsMockResponse, + MOCK_SNAPSHOT_ID, + MOCK_JOB_ID, + MOCK_REINDEX_DEPRECATION, +} from './mocked_responses'; describe('Index settings deprecation flyout', () => { let testBed: ElasticsearchTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; const indexSettingDeprecation = esDeprecationsMockResponse.deprecations[1]; - - afterAll(() => { - server.restore(); - }); - beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(esDeprecationsMockResponse); httpRequestsMockHelpers.setUpgradeMlSnapshotStatusResponse({ nodeId: 'my_node', @@ -28,7 +33,7 @@ describe('Index settings deprecation flyout', () => { jobId: MOCK_JOB_ID, status: 'idle', }); - httpRequestsMockHelpers.setReindexStatusResponse({ + httpRequestsMockHelpers.setReindexStatusResponse(MOCK_REINDEX_DEPRECATION.index!, { reindexOp: null, warnings: [], hasRequiredPrivileges: true, @@ -40,7 +45,7 @@ describe('Index settings deprecation flyout', () => { }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); const { actions, component } = testBed; @@ -48,7 +53,7 @@ describe('Index settings deprecation flyout', () => { await actions.table.clickDeprecationRowAt('indexSetting', 0); }); - test('renders a flyout with deprecation details', async () => { + it('renders a flyout with deprecation details', async () => { const { find, exists } = testBed; expect(exists('indexSettingsDetails')).toBe(true); @@ -64,7 +69,7 @@ describe('Index settings deprecation flyout', () => { it('removes deprecated index settings', async () => { const { find, actions, exists } = testBed; - httpRequestsMockHelpers.setUpdateIndexSettingsResponse({ + httpRequestsMockHelpers.setUpdateIndexSettingsResponse(indexSettingDeprecation.index!, { acknowledged: true, }); @@ -72,13 +77,10 @@ describe('Index settings deprecation flyout', () => { await actions.indexSettingsDeprecationFlyout.clickDeleteSettingsButton(); - const request = server.requests[server.requests.length - 1]; - - expect(request.method).toBe('POST'); - expect(request.url).toBe( - `/api/upgrade_assistant/${indexSettingDeprecation.index!}/index_settings` + expect(httpSetup.post).toHaveBeenLastCalledWith( + `/api/upgrade_assistant/${indexSettingDeprecation.index!}/index_settings`, + expect.anything() ); - expect(request.status).toEqual(200); // Verify the "Resolution" column of the table is updated expect(find('indexSettingsResolutionStatusCell').at(0).text()).toEqual( @@ -104,17 +106,18 @@ describe('Index settings deprecation flyout', () => { message: 'Remove index settings error', }; - httpRequestsMockHelpers.setUpdateIndexSettingsResponse(undefined, error); + httpRequestsMockHelpers.setUpdateIndexSettingsResponse( + indexSettingDeprecation.index!, + undefined, + error + ); await actions.indexSettingsDeprecationFlyout.clickDeleteSettingsButton(); - const request = server.requests[server.requests.length - 1]; - - expect(request.method).toBe('POST'); - expect(request.url).toBe( - `/api/upgrade_assistant/${indexSettingDeprecation.index!}/index_settings` + expect(httpSetup.post).toHaveBeenLastCalledWith( + `/api/upgrade_assistant/${indexSettingDeprecation.index!}/index_settings`, + expect.anything() ); - expect(request.status).toEqual(500); // Verify the "Resolution" column of the table is updated expect(find('indexSettingsResolutionStatusCell').at(0).text()).toEqual( diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/ml_snapshots_deprecation_flyout.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/ml_snapshots_deprecation_flyout.test.ts index 11bb27bb8b3310..2fa645a60d6d5b 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/ml_snapshots_deprecation_flyout.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/ml_snapshots_deprecation_flyout.test.ts @@ -14,14 +14,14 @@ import { esDeprecationsMockResponse, MOCK_SNAPSHOT_ID, MOCK_JOB_ID } from './moc describe('Machine learning deprecation flyout', () => { let testBed: ElasticsearchTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); const mlDeprecation = esDeprecationsMockResponse.deprecations[0]; - - afterAll(() => { - server.restore(); - }); - + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(esDeprecationsMockResponse); httpRequestsMockHelpers.setLoadMlUpgradeModeResponse({ mlUpgradeModeEnabled: false }); httpRequestsMockHelpers.setUpgradeMlSnapshotStatusResponse({ @@ -30,7 +30,7 @@ describe('Machine learning deprecation flyout', () => { jobId: MOCK_JOB_ID, status: 'idle', }); - httpRequestsMockHelpers.setReindexStatusResponse({ + httpRequestsMockHelpers.setReindexStatusResponse('reindex_index', { reindexOp: null, warnings: [], hasRequiredPrivileges: true, @@ -42,7 +42,7 @@ describe('Machine learning deprecation flyout', () => { }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(mockEnvironment.httpSetup, { isReadOnlyMode: false }); }); const { actions, component } = testBed; @@ -84,15 +84,15 @@ describe('Machine learning deprecation flyout', () => { await actions.mlDeprecationFlyout.clickUpgradeSnapshot(); // First, we expect a POST request to upgrade the snapshot - const upgradeRequest = server.requests[server.requests.length - 2]; - expect(upgradeRequest.method).toBe('POST'); - expect(upgradeRequest.url).toBe('/api/upgrade_assistant/ml_snapshots'); + expect(httpSetup.post).toHaveBeenLastCalledWith( + '/api/upgrade_assistant/ml_snapshots', + expect.anything() + ); // Next, we expect a GET request to check the status of the upgrade - const statusRequest = server.requests[server.requests.length - 1]; - expect(statusRequest.method).toBe('GET'); - expect(statusRequest.url).toBe( - `/api/upgrade_assistant/ml_snapshots/${MOCK_JOB_ID}/${MOCK_SNAPSHOT_ID}` + expect(httpSetup.get).toHaveBeenLastCalledWith( + `/api/upgrade_assistant/ml_snapshots/${MOCK_JOB_ID}/${MOCK_SNAPSHOT_ID}`, + expect.anything() ); // Verify the "Resolution" column of the table is updated @@ -128,9 +128,10 @@ describe('Machine learning deprecation flyout', () => { await actions.mlDeprecationFlyout.clickUpgradeSnapshot(); - const upgradeRequest = server.requests[server.requests.length - 1]; - expect(upgradeRequest.method).toBe('POST'); - expect(upgradeRequest.url).toBe('/api/upgrade_assistant/ml_snapshots'); + expect(httpSetup.post).toHaveBeenLastCalledWith( + '/api/upgrade_assistant/ml_snapshots', + expect.anything() + ); // Verify the "Resolution" column of the table is updated expect(find('mlActionResolutionCell').text()).toContain('Upgrade failed'); @@ -147,10 +148,12 @@ describe('Machine learning deprecation flyout', () => { }); it('Disables actions if ml_upgrade_mode is enabled', async () => { - httpRequestsMockHelpers.setLoadMlUpgradeModeResponse({ mlUpgradeModeEnabled: true }); + httpRequestsMockHelpers.setLoadMlUpgradeModeResponse({ + mlUpgradeModeEnabled: true, + }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); const { actions, exists, component } = testBed; @@ -172,7 +175,9 @@ describe('Machine learning deprecation flyout', () => { it('successfully deletes snapshots', async () => { const { find, actions, exists } = testBed; - httpRequestsMockHelpers.setDeleteMlSnapshotResponse({ + const jobId = (mlDeprecation.correctiveAction! as MlAction).jobId; + const snapshotId = (mlDeprecation.correctiveAction! as MlAction).snapshotId; + httpRequestsMockHelpers.setDeleteMlSnapshotResponse(jobId, snapshotId, { acknowledged: true, }); @@ -181,13 +186,9 @@ describe('Machine learning deprecation flyout', () => { await actions.mlDeprecationFlyout.clickDeleteSnapshot(); - const request = server.requests[server.requests.length - 1]; - - expect(request.method).toBe('DELETE'); - expect(request.url).toBe( - `/api/upgrade_assistant/ml_snapshots/${ - (mlDeprecation.correctiveAction! as MlAction).jobId - }/${(mlDeprecation.correctiveAction! as MlAction).snapshotId}` + expect(httpSetup.delete).toHaveBeenLastCalledWith( + `/api/upgrade_assistant/ml_snapshots/${jobId}/${snapshotId}`, + expect.anything() ); // Verify the "Resolution" column of the table is updated @@ -212,17 +213,15 @@ describe('Machine learning deprecation flyout', () => { message: 'Upgrade snapshot error', }; - httpRequestsMockHelpers.setDeleteMlSnapshotResponse(undefined, error); + const jobId = (mlDeprecation.correctiveAction! as MlAction).jobId; + const snapshotId = (mlDeprecation.correctiveAction! as MlAction).snapshotId; + httpRequestsMockHelpers.setDeleteMlSnapshotResponse(jobId, snapshotId, undefined, error); await actions.mlDeprecationFlyout.clickDeleteSnapshot(); - const request = server.requests[server.requests.length - 1]; - - expect(request.method).toBe('DELETE'); - expect(request.url).toBe( - `/api/upgrade_assistant/ml_snapshots/${ - (mlDeprecation.correctiveAction! as MlAction).jobId - }/${(mlDeprecation.correctiveAction! as MlAction).snapshotId}` + expect(httpSetup.delete).toHaveBeenLastCalledWith( + `/api/upgrade_assistant/ml_snapshots/${jobId}/${snapshotId}`, + expect.anything() ); // Verify the "Resolution" column of the table is updated diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/mocked_responses.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/mocked_responses.ts index ddf477195063c0..09f60a8dd3074d 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/mocked_responses.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/mocked_responses.ts @@ -26,7 +26,7 @@ export const MOCK_ML_DEPRECATION: EnrichedDeprecationInfo = { }, }; -const MOCK_REINDEX_DEPRECATION: EnrichedDeprecationInfo = { +export const MOCK_REINDEX_DEPRECATION: EnrichedDeprecationInfo = { isCritical: true, resolveDuringUpgrade: false, type: 'index_settings', diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/reindex_deprecation_flyout.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/reindex_deprecation_flyout.test.ts index 25742958aa243b..8067d3389115a9 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/reindex_deprecation_flyout.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/es_deprecations/reindex_deprecation_flyout.test.ts @@ -10,7 +10,12 @@ import { act } from 'react-dom/test-utils'; import { ReindexStatus, ReindexStep, ReindexStatusResponse } from '../../../common/types'; import { setupEnvironment } from '../helpers'; import { ElasticsearchTestBed, setupElasticsearchPage } from './es_deprecations.helpers'; -import { esDeprecationsMockResponse, MOCK_SNAPSHOT_ID, MOCK_JOB_ID } from './mocked_responses'; +import { + esDeprecationsMockResponse, + MOCK_SNAPSHOT_ID, + MOCK_JOB_ID, + MOCK_REINDEX_DEPRECATION, +} from './mocked_responses'; const defaultReindexStatusMeta: ReindexStatusResponse['meta'] = { indexName: 'foo', @@ -21,7 +26,6 @@ const defaultReindexStatusMeta: ReindexStatusResponse['meta'] = { // Note: The reindexing flyout UX is subject to change; more tests should be added here once functionality is built out describe('Reindex deprecation flyout', () => { let testBed: ElasticsearchTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { jest.useFakeTimers(); @@ -29,10 +33,15 @@ describe('Reindex deprecation flyout', () => { afterAll(() => { jest.useRealTimers(); - server.restore(); }); + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(esDeprecationsMockResponse); httpRequestsMockHelpers.setUpgradeMlSnapshotStatusResponse({ nodeId: 'my_node', @@ -40,7 +49,7 @@ describe('Reindex deprecation flyout', () => { jobId: MOCK_JOB_ID, status: 'idle', }); - httpRequestsMockHelpers.setReindexStatusResponse({ + httpRequestsMockHelpers.setReindexStatusResponse(MOCK_REINDEX_DEPRECATION.index!, { reindexOp: null, warnings: [], hasRequiredPrivileges: true, @@ -52,7 +61,7 @@ describe('Reindex deprecation flyout', () => { }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); testBed.component.update(); @@ -71,15 +80,8 @@ describe('Reindex deprecation flyout', () => { }); it('renders error callout when reindex fails', async () => { - httpRequestsMockHelpers.setReindexStatusResponse({ - reindexOp: null, - warnings: [], - hasRequiredPrivileges: true, - meta: defaultReindexStatusMeta, - }); - await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); testBed.component.update(); @@ -88,7 +90,7 @@ describe('Reindex deprecation flyout', () => { await actions.table.clickDeprecationRowAt('reindex', 0); - httpRequestsMockHelpers.setStartReindexingResponse(undefined, { + httpRequestsMockHelpers.setStartReindexingResponse(MOCK_REINDEX_DEPRECATION.index!, undefined, { statusCode: 404, message: 'no such index [test]', }); @@ -99,13 +101,13 @@ describe('Reindex deprecation flyout', () => { }); it('renders error callout when fetch status fails', async () => { - httpRequestsMockHelpers.setReindexStatusResponse(undefined, { + httpRequestsMockHelpers.setReindexStatusResponse(MOCK_REINDEX_DEPRECATION.index!, undefined, { statusCode: 404, message: 'no such index [test]', }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); testBed.component.update(); @@ -127,7 +129,7 @@ describe('Reindex deprecation flyout', () => { }); it('has started but not yet reindexing documents', async () => { - httpRequestsMockHelpers.setReindexStatusResponse({ + httpRequestsMockHelpers.setReindexStatusResponse(MOCK_REINDEX_DEPRECATION.index!, { reindexOp: { status: ReindexStatus.inProgress, lastCompletedStep: ReindexStep.readonly, @@ -139,7 +141,7 @@ describe('Reindex deprecation flyout', () => { }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); testBed.component.update(); @@ -152,7 +154,7 @@ describe('Reindex deprecation flyout', () => { }); it('has started reindexing documents', async () => { - httpRequestsMockHelpers.setReindexStatusResponse({ + httpRequestsMockHelpers.setReindexStatusResponse(MOCK_REINDEX_DEPRECATION.index!, { reindexOp: { status: ReindexStatus.inProgress, lastCompletedStep: ReindexStep.reindexStarted, @@ -164,7 +166,7 @@ describe('Reindex deprecation flyout', () => { }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); testBed.component.update(); @@ -177,7 +179,7 @@ describe('Reindex deprecation flyout', () => { }); it('has completed reindexing documents', async () => { - httpRequestsMockHelpers.setReindexStatusResponse({ + httpRequestsMockHelpers.setReindexStatusResponse(MOCK_REINDEX_DEPRECATION.index!, { reindexOp: { status: ReindexStatus.inProgress, lastCompletedStep: ReindexStep.reindexCompleted, @@ -189,7 +191,7 @@ describe('Reindex deprecation flyout', () => { }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); testBed.component.update(); @@ -202,7 +204,7 @@ describe('Reindex deprecation flyout', () => { }); it('has completed', async () => { - httpRequestsMockHelpers.setReindexStatusResponse({ + httpRequestsMockHelpers.setReindexStatusResponse(MOCK_REINDEX_DEPRECATION.index!, { reindexOp: { status: ReindexStatus.completed, lastCompletedStep: ReindexStep.aliasCreated, @@ -214,7 +216,7 @@ describe('Reindex deprecation flyout', () => { }); await act(async () => { - testBed = await setupElasticsearchPage({ isReadOnlyMode: false }); + testBed = await setupElasticsearchPage(httpSetup, { isReadOnlyMode: false }); }); const { actions, find, exists, component } = testBed; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/http_requests.ts index 774dfba4c1b9f9..70a7efecd71838 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/http_requests.ts @@ -5,7 +5,7 @@ * 2.0. */ -import sinon, { SinonFakeServer } from 'sinon'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; import { API_BASE_PATH } from '../../../common/constants'; import { @@ -15,204 +15,132 @@ import { ResponseError, } from '../../../common/types'; +type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST'; + // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { - const setLoadCloudBackupStatusResponse = ( - response?: CloudBackupStatus, - error?: ResponseError - ) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; +const registerHttpRequestMockHelpers = ( + httpSetup: ReturnType, + shouldDelayResponse: () => boolean +) => { + const mockResponses = new Map>>( + ['GET', 'PUT', 'DELETE', 'POST'].map( + (method) => [method, new Map()] as [HttpMethod, Map>] + ) + ); + + const mockMethodImplementation = (method: HttpMethod, path: string) => { + const responsePromise = mockResponses.get(method)?.get(path) ?? Promise.resolve({}); + if (shouldDelayResponse()) { + return new Promise((resolve) => { + setTimeout(() => resolve(responsePromise), 1000); + }); + } - server.respondWith('GET', `${API_BASE_PATH}/cloud_backup_status`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); + return responsePromise; }; - const setLoadEsDeprecationsResponse = (response?: ESUpgradeStatus, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; + httpSetup.get.mockImplementation((path) => + mockMethodImplementation('GET', path as unknown as string) + ); + httpSetup.delete.mockImplementation((path) => + mockMethodImplementation('DELETE', path as unknown as string) + ); + httpSetup.post.mockImplementation((path) => + mockMethodImplementation('POST', path as unknown as string) + ); + httpSetup.put.mockImplementation((path) => + mockMethodImplementation('PUT', path as unknown as string) + ); - server.respondWith('GET', `${API_BASE_PATH}/es_deprecations`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); + const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => { + const defuse = (promise: Promise) => { + promise.catch(() => {}); + return promise; + }; + + return mockResponses + .get(method)! + .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response)); }; + const setLoadCloudBackupStatusResponse = (response?: CloudBackupStatus, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/cloud_backup_status`, response, error); + + const setLoadEsDeprecationsResponse = (response?: ESUpgradeStatus, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/es_deprecations`, response, error); + const setLoadDeprecationLoggingResponse = ( response?: DeprecationLoggingStatus, error?: ResponseError - ) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; - - server.respondWith('GET', `${API_BASE_PATH}/deprecation_logging`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + ) => mockResponse('GET', `${API_BASE_PATH}/deprecation_logging`, response, error); const setLoadDeprecationLogsCountResponse = ( response?: { count: number }, error?: ResponseError - ) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; - - server.respondWith('GET', `${API_BASE_PATH}/deprecation_logging/count`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + ) => mockResponse('GET', `${API_BASE_PATH}/deprecation_logging/count`, response, error); - const setDeleteLogsCacheResponse = (response?: string, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; - server.respondWith('DELETE', `${API_BASE_PATH}/deprecation_logging/cache`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + const setDeleteLogsCacheResponse = (response?: string, error?: ResponseError) => + mockResponse('DELETE', `${API_BASE_PATH}/deprecation_logging/cache`, response, error); const setUpdateDeprecationLoggingResponse = ( response?: DeprecationLoggingStatus, error?: ResponseError - ) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; + ) => mockResponse('PUT', `${API_BASE_PATH}/deprecation_logging`, response, error); - server.respondWith('PUT', `${API_BASE_PATH}/deprecation_logging`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setUpdateIndexSettingsResponse = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; - server.respondWith('POST', `${API_BASE_PATH}/:indexName/index_settings`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setUpgradeMlSnapshotResponse = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; - - server.respondWith('POST', `${API_BASE_PATH}/ml_snapshots`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setUpgradeMlSnapshotStatusResponse = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; - - server.respondWith('GET', `${API_BASE_PATH}/ml_snapshots/:jobId/:snapshotId`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setReindexStatusResponse = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; - - server.respondWith('GET', `${API_BASE_PATH}/reindex/:indexName`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setStartReindexingResponse = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; - - server.respondWith('POST', `${API_BASE_PATH}/reindex/:indexName`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setDeleteMlSnapshotResponse = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; - - server.respondWith('DELETE', `${API_BASE_PATH}/ml_snapshots/:jobId/:snapshotId`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setLoadSystemIndicesMigrationStatus = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; + const setUpdateIndexSettingsResponse = ( + indexName: string, + response?: object, + error?: ResponseError + ) => mockResponse('POST', `${API_BASE_PATH}/${indexName}/index_settings`, response, error); - server.respondWith('GET', `${API_BASE_PATH}/system_indices_migration`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + const setUpgradeMlSnapshotResponse = (response?: object, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/ml_snapshots`, response, error); - const setLoadMlUpgradeModeResponse = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; + const setUpgradeMlSnapshotStatusResponse = ( + response?: Record, + error?: ResponseError + ) => + mockResponse( + 'GET', + `${API_BASE_PATH}/ml_snapshots/${response?.jobId}/${response?.snapshotId}`, + response, + error + ); + + const setReindexStatusResponse = ( + indexName: string, + response?: Record, + error?: ResponseError + ) => mockResponse('GET', `${API_BASE_PATH}/reindex/${indexName}`, response, error); - server.respondWith('GET', `${API_BASE_PATH}/ml_upgrade_mode`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + const setStartReindexingResponse = ( + indexName: string, + response?: object, + error?: ResponseError + ) => mockResponse('POST', `${API_BASE_PATH}/reindex/${indexName}`, response, error); - const setSystemIndicesMigrationResponse = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; + const setDeleteMlSnapshotResponse = ( + jobId: string, + snapshotId: string, + response?: object, + error?: ResponseError + ) => + mockResponse('DELETE', `${API_BASE_PATH}/ml_snapshots/${jobId}/${snapshotId}`, response, error); - server.respondWith('POST', `${API_BASE_PATH}/system_indices_migration`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + const setLoadSystemIndicesMigrationStatus = (response?: object, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/system_indices_migration`, response, error); - const setGetUpgradeStatusResponse = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; + const setLoadMlUpgradeModeResponse = (response?: object, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/ml_upgrade_mode`, response, error); - server.respondWith('GET', `${API_BASE_PATH}/status`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + const setSystemIndicesMigrationResponse = (response?: object, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/system_indices_migration`, response, error); - const setLoadRemoteClustersResponse = (response?: object, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ? error : response; + const setGetUpgradeStatusResponse = (response?: object, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/status`, response, error); - server.respondWith('GET', `${API_BASE_PATH}/remote_clusters`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + const setLoadRemoteClustersResponse = (response?: object, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/remote_clusters`, response, error); return { setLoadCloudBackupStatusResponse, @@ -236,29 +164,18 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { }; export const init = () => { - const server = sinon.fakeServer.create(); - server.respondImmediately = true; - - // Define default response for unhandled requests. - // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, - // and we can mock them all with a 200 instead of mocking each one individually. - server.respondWith([200, {}, 'DefaultMockedResponse']); - - const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); - - const setServerAsync = (isAsync: boolean, timeout: number = 200) => { - if (isAsync) { - server.autoRespond = true; - server.autoRespondAfter = 1000; - server.respondImmediately = false; - } else { - server.respondImmediately = true; - } + let isResponseDelayed = false; + const getDelayResponse = () => isResponseDelayed; + const setDelayResponse = (shouldDelayResponse: boolean) => { + isResponseDelayed = shouldDelayResponse; }; + const httpSetup = httpServiceMock.createSetupContract(); + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup, getDelayResponse); + return { - server, - setServerAsync, + setDelayResponse, + httpSetup, httpRequestsMockHelpers, }; }; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx index 0e4af0b697a496..7bc6931550a3b7 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,11 +6,8 @@ */ import React from 'react'; -import axios from 'axios'; import SemVer from 'semver/classes/semver'; import { merge } from 'lodash'; -// @ts-ignore -import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { HttpSetup } from 'src/core/public'; import { MAJOR_VERSION } from '../../../common/constants'; @@ -26,8 +23,6 @@ import { init as initHttpRequests } from './http_requests'; const { GlobalFlyoutProvider } = GlobalFlyout; -const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); - export const kibanaVersion = new SemVer(MAJOR_VERSION); const createAuthorizationContextValue = (privileges: Privileges) => { @@ -38,9 +33,9 @@ const createAuthorizationContextValue = (privileges: Privileges) => { }; export const WithAppDependencies = - (Comp: any, { privileges, ...overrides }: Record = {}) => + (Comp: any, httpSetup: HttpSetup, { privileges, ...overrides }: Record = {}) => (props: Record) => { - apiService.setup(mockHttpClient as unknown as HttpSetup); + apiService.setup(httpSetup); breadcrumbService.setup(() => ''); const appContextMock = getAppContextMock(kibanaVersion) as unknown as AppDependencies; @@ -59,11 +54,5 @@ export const WithAppDependencies = }; export const setupEnvironment = () => { - const { server, setServerAsync, httpRequestsMockHelpers } = initHttpRequests(); - - return { - server, - setServerAsync, - httpRequestsMockHelpers, - }; + return initHttpRequests(); }; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecation_details_flyout/deprecation_details_flyout.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecation_details_flyout/deprecation_details_flyout.test.ts index 9677104a6e558e..e235bdc6f4543c 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecation_details_flyout/deprecation_details_flyout.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecation_details_flyout/deprecation_details_flyout.test.ts @@ -14,21 +14,15 @@ import { KibanaTestBed, setupKibanaPage } from '../kibana_deprecations.helpers'; describe('Kibana deprecations - Deprecation details flyout', () => { let testBed: KibanaTestBed; - const { server } = setupEnvironment(); const { defaultMockedResponses: { mockedKibanaDeprecations }, } = kibanaDeprecationsServiceHelpers; const deprecationService = deprecationsServiceMock.createStartContract(); - - afterAll(() => { - server.restore(); - }); - beforeEach(async () => { await act(async () => { kibanaDeprecationsServiceHelpers.setLoadDeprecations({ deprecationService }); - testBed = await setupKibanaPage({ + testBed = await setupKibanaPage(setupEnvironment().httpSetup, { services: { core: { deprecations: deprecationService, diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecations_table/deprecations_table.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecations_table/deprecations_table.test.ts index a14d6e087b017f..6e63d150c09f8c 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecations_table/deprecations_table.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecations_table/deprecations_table.test.ts @@ -17,7 +17,6 @@ describe('Kibana deprecations - Deprecations table', () => { let testBed: KibanaTestBed; let deprecationService: jest.Mocked; - const { server } = setupEnvironment(); const { mockedKibanaDeprecations, mockedCriticalKibanaDeprecations, @@ -25,17 +24,16 @@ describe('Kibana deprecations - Deprecations table', () => { mockedConfigKibanaDeprecations, } = kibanaDeprecationsServiceHelpers.defaultMockedResponses; - afterAll(() => { - server.restore(); - }); - + let httpSetup: ReturnType['httpSetup']; beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpSetup = mockEnvironment.httpSetup; deprecationService = deprecationsServiceMock.createStartContract(); await act(async () => { kibanaDeprecationsServiceHelpers.setLoadDeprecations({ deprecationService }); - testBed = await setupKibanaPage({ + testBed = await setupKibanaPage(httpSetup, { services: { core: { deprecations: deprecationService, @@ -108,7 +106,7 @@ describe('Kibana deprecations - Deprecations table', () => { describe('No deprecations', () => { beforeEach(async () => { await act(async () => { - testBed = await setupKibanaPage({ isReadOnlyMode: false }); + testBed = await setupKibanaPage(httpSetup, { isReadOnlyMode: false }); }); const { component } = testBed; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecations_table/error_handling.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecations_table/error_handling.test.ts index 918ee759a0f454..13616286552eff 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecations_table/error_handling.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/deprecations_table/error_handling.test.ts @@ -14,11 +14,12 @@ import { KibanaTestBed, setupKibanaPage } from '../kibana_deprecations.helpers'; describe('Kibana deprecations - Deprecations table - Error handling', () => { let testBed: KibanaTestBed; - const { server } = setupEnvironment(); const deprecationService = deprecationsServiceMock.createStartContract(); - afterAll(() => { - server.restore(); + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpSetup = mockEnvironment.httpSetup; }); test('handles plugin errors', async () => { @@ -57,7 +58,7 @@ describe('Kibana deprecations - Deprecations table - Error handling', () => { ], }); - testBed = await setupKibanaPage({ + testBed = await setupKibanaPage(httpSetup, { services: { core: { deprecations: deprecationService, @@ -83,7 +84,7 @@ describe('Kibana deprecations - Deprecations table - Error handling', () => { mockRequestErrorMessage: 'Internal Server Error', }); - testBed = await setupKibanaPage({ + testBed = await setupKibanaPage(httpSetup, { services: { core: { deprecations: deprecationService, diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/kibana_deprecations.helpers.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/kibana_deprecations.helpers.ts index dba077fc303d26..bde2a78b133025 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/kibana_deprecations.helpers.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/kibana_deprecations/kibana_deprecations.helpers.ts @@ -11,6 +11,7 @@ import { AsyncTestBedConfig, findTestSubject, } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { KibanaDeprecations } from '../../../public/application/components'; import { WithAppDependencies } from '../helpers'; @@ -118,10 +119,11 @@ const createActions = (testBed: TestBed) => { }; export const setupKibanaPage = async ( + httpSetup: HttpSetup, overrides?: Record ): Promise => { const initTestBed = registerTestBed( - WithAppDependencies(KibanaDeprecations, overrides), + WithAppDependencies(KibanaDeprecations, httpSetup, overrides), testBedConfig ); const testBed = await initTestBed(); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/backup_step/backup_step.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/backup_step/backup_step.test.tsx index 4cd4bf3f766298..688e060705ee42 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/backup_step/backup_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/backup_step/backup_step.test.tsx @@ -14,21 +14,19 @@ import { OverviewTestBed, setupOverviewPage } from '../overview.helpers'; describe('Overview - Backup Step', () => { let testBed: OverviewTestBed; - let server: ReturnType['server']; - let setServerAsync: ReturnType['setServerAsync']; let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; - - beforeEach(() => { - ({ server, setServerAsync, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterEach(() => { - server.restore(); + let httpSetup: ReturnType['httpSetup']; + let setDelayResponse: ReturnType['setDelayResponse']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; + setDelayResponse = mockEnvironment.setDelayResponse; }); describe('On-prem', () => { beforeEach(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); test('Shows link to Snapshot and Restore', () => { @@ -45,7 +43,7 @@ describe('Overview - Backup Step', () => { describe('On Cloud', () => { const setupCloudOverviewPage = async () => - setupOverviewPage({ + setupOverviewPage(httpSetup, { plugins: { cloud: { isCloudEnabled: true, @@ -57,14 +55,10 @@ describe('Overview - Backup Step', () => { describe('initial loading state', () => { beforeEach(async () => { // We don't want the request to load backup status to resolve immediately. - setServerAsync(true); + setDelayResponse(true); testBed = await setupCloudOverviewPage(); }); - afterEach(() => { - setServerAsync(false); - }); - test('is rendered', () => { const { exists } = testBed; expect(exists('cloudBackupLoading')).toBe(true); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/elasticsearch_deprecation_issues.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/elasticsearch_deprecation_issues.test.tsx index e1cef64dfb20ca..8671b136844ed1 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/elasticsearch_deprecation_issues.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/elasticsearch_deprecation_issues.test.tsx @@ -19,10 +19,12 @@ import { describe('Overview - Fix deprecation issues step - Elasticsearch deprecations', () => { let testBed: OverviewTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); describe('When load succeeds', () => { @@ -32,7 +34,7 @@ describe('Overview - Fix deprecation issues step - Elasticsearch deprecations', const deprecationService = deprecationsServiceMock.createStartContract(); kibanaDeprecationsServiceHelpers.setLoadDeprecations({ deprecationService, response: [] }); - testBed = await setupOverviewPage({ + testBed = await setupOverviewPage(httpSetup, { services: { core: { deprecations: deprecationService, @@ -116,7 +118,7 @@ describe('Overview - Fix deprecation issues step - Elasticsearch deprecations', httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); await act(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); const { component, find } = testBed; @@ -136,7 +138,7 @@ describe('Overview - Fix deprecation issues step - Elasticsearch deprecations', httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); await act(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); const { component, find } = testBed; @@ -159,7 +161,7 @@ describe('Overview - Fix deprecation issues step - Elasticsearch deprecations', httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); await act(async () => { - testBed = await setupOverviewPage({ isReadOnlyMode: false }); + testBed = await setupOverviewPage(httpSetup, { isReadOnlyMode: false }); }); const { component, find } = testBed; @@ -182,7 +184,7 @@ describe('Overview - Fix deprecation issues step - Elasticsearch deprecations', httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); await act(async () => { - testBed = await setupOverviewPage({ isReadOnlyMode: false }); + testBed = await setupOverviewPage(httpSetup, { isReadOnlyMode: false }); }); const { component, find } = testBed; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/fix_issues_step.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/fix_issues_step.test.tsx index b7c417fbfcb8d6..cc2cec97cc702b 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/fix_issues_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/fix_issues_step.test.tsx @@ -15,10 +15,12 @@ import { esCriticalAndWarningDeprecations, esNoDeprecations } from './mock_es_is describe('Overview - Fix deprecation issues step', () => { let testBed: OverviewTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); describe('when there are critical issues in one panel', () => { @@ -29,7 +31,7 @@ describe('Overview - Fix deprecation issues step', () => { const deprecationService = deprecationsServiceMock.createStartContract(); kibanaDeprecationsServiceHelpers.setLoadDeprecations({ deprecationService, response: [] }); - testBed = await setupOverviewPage({ + testBed = await setupOverviewPage(httpSetup, { services: { core: { deprecations: deprecationService, @@ -55,7 +57,7 @@ describe('Overview - Fix deprecation issues step', () => { const deprecationService = deprecationsServiceMock.createStartContract(); kibanaDeprecationsServiceHelpers.setLoadDeprecations({ deprecationService, response: [] }); - testBed = await setupOverviewPage({ + testBed = await setupOverviewPage(httpSetup, { services: { core: { deprecations: deprecationService, diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/kibana_deprecation_issues.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/kibana_deprecation_issues.test.tsx index c11a1481b68b5d..f060a38440ec15 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/kibana_deprecation_issues.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/fix_issues_step/kibana_deprecation_issues.test.tsx @@ -16,12 +16,14 @@ import { esNoDeprecations } from './mock_es_issues'; describe('Overview - Fix deprecation issues step - Kibana deprecations', () => { let testBed: OverviewTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); const { mockedKibanaDeprecations, mockedCriticalKibanaDeprecations } = kibanaDeprecationsServiceHelpers.defaultMockedResponses; - - afterAll(() => { - server.restore(); + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); describe('When load succeeds', () => { @@ -33,7 +35,7 @@ describe('Overview - Fix deprecation issues step - Kibana deprecations', () => { const deprecationService = deprecationsServiceMock.createStartContract(); kibanaDeprecationsServiceHelpers.setLoadDeprecations({ deprecationService, response }); - testBed = await setupOverviewPage({ + testBed = await setupOverviewPage(httpSetup, { services: { core: { deprecations: deprecationService, @@ -114,7 +116,7 @@ describe('Overview - Fix deprecation issues step - Kibana deprecations', () => { mockRequestErrorMessage: 'Internal Server Error', }); - testBed = await setupOverviewPage({ + testBed = await setupOverviewPage(httpSetup, { services: { core: { deprecations: deprecationService, diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/logs_step/logs_step.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/logs_step/logs_step.test.tsx index 31cbb8ebef4567..0a8c6cab48256f 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/logs_step/logs_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/logs_step/logs_step.test.tsx @@ -12,11 +12,12 @@ import { OverviewTestBed, setupOverviewPage } from '../overview.helpers'; describe('Overview - Logs Step', () => { let testBed: OverviewTestBed; - - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); describe('error state', () => { @@ -30,7 +31,7 @@ describe('Overview - Logs Step', () => { httpRequestsMockHelpers.setLoadDeprecationLogsCountResponse(undefined, error); await act(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); testBed.component.update(); @@ -58,7 +59,7 @@ describe('Overview - Logs Step', () => { }); await act(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); const { component, exists } = testBed; @@ -74,7 +75,7 @@ describe('Overview - Logs Step', () => { }); await act(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); const { component, exists } = testBed; @@ -90,7 +91,7 @@ describe('Overview - Logs Step', () => { }); await act(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); const { component, find } = testBed; @@ -110,7 +111,7 @@ describe('Overview - Logs Step', () => { }); await act(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); const { component } = testBed; @@ -135,7 +136,7 @@ describe('Overview - Logs Step', () => { }); await act(async () => { - testBed = await setupOverviewPage({ + testBed = await setupOverviewPage(httpSetup, { privileges: { hasAllPrivileges: true, missingPrivileges: { diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/flyout.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/flyout.test.ts index 1e74a966b3933e..2d2e917555b748 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/flyout.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/flyout.test.ts @@ -13,22 +13,22 @@ import { systemIndicesMigrationStatus } from './mocks'; describe('Overview - Migrate system indices - Flyout', () => { let testBed: OverviewTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; + httpRequestsMockHelpers.setLoadSystemIndicesMigrationStatus(systemIndicesMigrationStatus); await act(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); testBed.component.update(); }); - afterAll(() => { - server.restore(); - }); - test('shows correct features in flyout table', async () => { const { actions, table } = testBed; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/migrate_system_indices.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/migrate_system_indices.test.tsx index e3f6d747deaed5..ae3e184f9c96bd 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/migrate_system_indices.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/migrate_system_indices.test.tsx @@ -12,15 +12,15 @@ import { OverviewTestBed, setupOverviewPage } from '../overview.helpers'; describe('Overview - Migrate system indices', () => { let testBed: OverviewTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; beforeEach(async () => { - testBed = await setupOverviewPage(); - testBed.component.update(); - }); + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; - afterAll(() => { - server.restore(); + testBed = await setupOverviewPage(httpSetup); + testBed.component.update(); }); describe('Error state', () => { @@ -30,7 +30,7 @@ describe('Overview - Migrate system indices', () => { message: 'error', }); - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); test('Is rendered', () => { @@ -59,7 +59,7 @@ describe('Overview - Migrate system indices', () => { migration_status: 'NO_MIGRATION_NEEDED', }); - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); const { exists, component } = testBed; @@ -75,7 +75,7 @@ describe('Overview - Migrate system indices', () => { migration_status: 'IN_PROGRESS', }); - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); const { exists, component, find } = testBed; @@ -94,7 +94,7 @@ describe('Overview - Migrate system indices', () => { migration_status: 'MIGRATION_NEEDED', }); - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); const { exists, component, find } = testBed; @@ -116,7 +116,7 @@ describe('Overview - Migrate system indices', () => { message: 'error', }); - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); const { exists, component, find } = testBed; @@ -154,7 +154,7 @@ describe('Overview - Migrate system indices', () => { ], }); - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); const { exists } = testBed; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/step_completion.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/step_completion.test.ts index 9eb0831c3c7a05..cbece74355d6d7 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/step_completion.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/migrate_system_indices/step_completion.test.ts @@ -13,10 +13,12 @@ import { SYSTEM_INDICES_MIGRATION_POLL_INTERVAL_MS } from '../../../../common/co describe('Overview - Migrate system indices - Step completion', () => { let testBed: OverviewTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); test(`It's complete when no upgrade is needed`, async () => { @@ -25,7 +27,7 @@ describe('Overview - Migrate system indices - Step completion', () => { }); await act(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); const { exists, component } = testBed; @@ -41,7 +43,7 @@ describe('Overview - Migrate system indices - Step completion', () => { }); await act(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); const { exists, component } = testBed; @@ -60,7 +62,7 @@ describe('Overview - Migrate system indices - Step completion', () => { migration_status: 'IN_PROGRESS', }); - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); }); afterEach(() => { diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.helpers.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.helpers.ts index 30a531091f1666..3f56a971b6bf9e 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.helpers.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.helpers.ts @@ -7,7 +7,8 @@ import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; -import { Overview } from '../../../public/application/components/overview'; +import { HttpSetup } from 'src/core/public'; +import { Overview } from '../../../public/application/components'; import { WithAppDependencies } from '../helpers'; const testBedConfig: AsyncTestBedConfig = { @@ -54,9 +55,13 @@ const createActions = (testBed: TestBed) => { }; export const setupOverviewPage = async ( + httpSetup: HttpSetup, overrides?: Record ): Promise => { - const initTestBed = registerTestBed(WithAppDependencies(Overview, overrides), testBedConfig); + const initTestBed = registerTestBed( + WithAppDependencies(Overview, httpSetup, overrides), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx index 2d318f60149d04..5e49fd510686fa 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx @@ -10,17 +10,11 @@ import { OverviewTestBed, setupOverviewPage } from './overview.helpers'; describe('Overview Page', () => { let testBed: OverviewTestBed; - const { server } = setupEnvironment(); - beforeEach(async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(setupEnvironment().httpSetup); testBed.component.update(); }); - afterAll(() => { - server.restore(); - }); - describe('Documentation links', () => { test('Has a whatsNew link and it references target version', () => { const { exists, find } = testBed; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/upgrade_step/upgrade_step.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/upgrade_step/upgrade_step.test.tsx index 9a4655d9d8ddb7..dc0f7c0f7c6b0c 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/upgrade_step/upgrade_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/upgrade_step/upgrade_step.test.tsx @@ -9,28 +9,33 @@ import { setupEnvironment } from '../../helpers'; import { OverviewTestBed, setupOverviewPage } from '../overview.helpers'; const DEPLOYMENT_URL = 'https://cloud.elastic.co./deployments/bfdad4ef99a24212a06d387593686d63'; -const setupCloudOverviewPage = () => { - return setupOverviewPage({ - plugins: { - cloud: { - isCloudEnabled: true, - deploymentUrl: DEPLOYMENT_URL, - }, - }, - }); -}; describe('Overview - Upgrade Step', () => { let testBed: OverviewTestBed; - const { server, httpRequestsMockHelpers, setServerAsync } = setupEnvironment(); + let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; + let httpSetup: ReturnType['httpSetup']; + let setDelayResponse: ReturnType['setDelayResponse']; + const setupCloudOverviewPage = () => { + return setupOverviewPage(httpSetup, { + plugins: { + cloud: { + isCloudEnabled: true, + deploymentUrl: DEPLOYMENT_URL, + }, + }, + }); + }; - afterAll(() => { - server.restore(); + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; + setDelayResponse = mockEnvironment.setDelayResponse; }); describe('On-prem', () => { test('Shows link to setup upgrade docs', async () => { - testBed = await setupOverviewPage(); + testBed = await setupOverviewPage(httpSetup); const { exists } = testBed; expect(exists('upgradeSetupDocsLink')).toBe(true); @@ -75,10 +80,10 @@ describe('Overview - Upgrade Step', () => { }); test('An error callout is displayed, if status check failed', async () => { - httpRequestsMockHelpers.setGetUpgradeStatusResponse( - {}, - { statusCode: 500, message: 'Status check failed' } - ); + httpRequestsMockHelpers.setGetUpgradeStatusResponse(undefined, { + statusCode: 500, + message: 'Status check failed', + }); testBed = await setupCloudOverviewPage(); const { exists, component } = testBed; @@ -90,7 +95,7 @@ describe('Overview - Upgrade Step', () => { }); test('The CTA button displays loading indicator', async () => { - setServerAsync(true); + setDelayResponse(true); testBed = await setupCloudOverviewPage(); const { exists, find } = testBed; From 689df5a4341a354a9cb97329266e22004f0732b7 Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Wed, 16 Mar 2022 10:21:05 +0100 Subject: [PATCH 22/39] removing export * (#127755) --- src/plugins/data/common/index.ts | 94 +++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 7 deletions(-) diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index da434e0cd77913..a97b8025426f26 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -9,14 +9,94 @@ // TODO: https://github.com/elastic/kibana/issues/109904 /* eslint-disable @kbn/eslint/no_export_all */ -export * from './constants'; -export * from './datatable_utilities'; -export * from './es_query'; -export * from './kbn_field_types'; -export * from './query'; +export { DEFAULT_QUERY_LANGUAGE, KIBANA_USER_QUERY_LANGUAGE_KEY, UI_SETTINGS } from './constants'; +export type { ValueSuggestionsMethod } from './constants'; +export { DatatableUtilitiesService } from './datatable_utilities'; +export { + buildEmptyFilter, + buildCustomFilter, + buildExistsFilter, + buildPhraseFilter, + buildPhrasesFilter, + buildQueryFilter, + buildQueryFromFilters, + buildRangeFilter, + buildFilter, + buildEsQuery, + getPhraseFilterField, + getPhraseFilterValue, + isExistsFilter, + compareFilters, + dedupFilters, + disableFilter, + enableFilter, + isPhraseFilter, + isFilters, + isQueryStringFilter, + isRangeFilter, + isPhrasesFilter, + decorateQuery, + fromKueryExpression, + isFilterDisabled, + isFilterPinned, + isMatchAllFilter, + FilterStateStore, + COMPARE_ALL_OPTIONS, + FILTERS, + getEsQueryConfig, + luceneStringToDsl, + nodeBuilder, + nodeTypes, + onlyDisabledFiltersChanged, + pinFilter, + toElasticsearchQuery, + toggleFilterDisabled, + toggleFilterNegated, + uniqFilters, +} from './es_query'; +export type { + ExistsFilter, + Filter, + MatchAllFilter, + FilterMeta, + PhraseFilter, + RangeFilter, + RangeFilterParams, + KueryNode, + EsQueryConfig, +} from './es_query'; +export { KbnFieldType } from './kbn_field_types'; +export { + calculateBounds, + getAbsoluteTimeRange, + getRelativeTime, + getTime, + isQuery, + isTimeRange, +} from './query'; export * from './search'; -export * from './types'; -export * from './exports'; +export type { + RefreshInterval, + TimeRange, + TimeRangeBounds, + GetConfigFn, + SavedQuery, + SavedQueryAttributes, + SavedQueryTimeFilter, + FilterValueFormatter, + KbnFieldTypeOptions, + Query, +} from './types'; +export { KBN_FIELD_TYPES, ES_FIELD_TYPES } from './types'; + +export { + createEscapeValue, + tableHasFormulas, + datatableToCSV, + cellHasFormulas, + CSV_FORMULA_CHARS, + CSV_MIME_TYPE, +} from './exports'; export type { IFieldType, IIndexPatternFieldList, From 574ba8de085ec49a8cb9d7886e40f779d3bcbbe9 Mon Sep 17 00:00:00 2001 From: Gloria Hornero Date: Wed, 16 Mar 2022 10:26:31 +0100 Subject: [PATCH 23/39] [Security Solution] Adds missing locators on the alerts details flyout (#127823) * adds missing locators on the alerts details flyout * adjust test to the new locators * fixes type check issue happening after solving the merge conflicts * updates jest snapshot --- .../cypress/screens/alerts_details.ts | 10 ++++++++-- .../detections/detection_rules/threshold_rule.spec.ts | 10 +++++++--- .../overview/__snapshots__/index.test.tsx.snap | 3 +++ .../common/components/event_details/overview/index.tsx | 1 + .../event_details/overview/overview_card.tsx | 4 +++- .../event_details/overview/status_popover_button.tsx | 1 + .../body/renderers/formatted_field_helpers.tsx | 2 +- 7 files changed, 24 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts index a6e61d536dd3ba..ddb100367b2422 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts @@ -33,13 +33,19 @@ export const JSON_TEXT = '[data-test-subj="jsonView"]'; export const OVERVIEW_HOST_NAME = '[data-test-subj="eventDetails"] [data-test-subj="host-details-button"]'; -export const OVERVIEW_SEVERITY = '[data-test-subj="eventDetails"] [data-test-subj=severity]'; +export const OVERVIEW_RISK_SCORE = '[data-test-subj="eventDetails"] [data-test-subj="riskScore"]'; + +export const OVERVIEW_RULE = '[data-test-subj="eventDetails"] [data-test-subj="ruleName"]'; + +export const OVERVIEW_SEVERITY = '[data-test-subj="eventDetails"] [data-test-subj="severity"]'; + +export const OVERVIEW_STATUS = '[data-test-subj="eventDetails"] [data-test-subj="alertStatus"]'; export const OVERVIEW_THRESHOLD_COUNT = '[data-test-subj="eventDetails"] [data-test-subj^=formatted][data-test-subj$=threshold_result\\.count]'; export const OVERVIEW_THRESHOLD_VALUE = - '[data-test-subj="eventDetails"] [data-test-subj^=formatted][data-test-subj$=threshold_result\\.terms\\.field]'; + '[data-test-subj="eventDetails"] [data-test-subj="formatted-field-kibana.alert.threshold_result.terms.field"]'; export const SUMMARY_VIEW = '[data-test-subj="summary-view"]'; diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts index 059f60d06de5c3..29c4c0c55128d3 100644 --- a/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/detections/detection_rules/threshold_rule.spec.ts @@ -36,7 +36,10 @@ import { loginAndWaitForPage } from '../../../tasks/login'; import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; import { OVERVIEW_HOST_NAME, + OVERVIEW_RISK_SCORE, + OVERVIEW_RULE, OVERVIEW_SEVERITY, + OVERVIEW_STATUS, OVERVIEW_THRESHOLD_COUNT, OVERVIEW_THRESHOLD_VALUE, SUMMARY_VIEW, @@ -120,9 +123,10 @@ describe('After an upgrade, the threshold rule', () => { it('Displays the Overview alert details in the alert flyout', () => { expandFirstAlert(); - // TODO: Add verification of OVERVIEW_STATUS, OVERVIEW_RULE, - // OVERVIEW_RISK_CODE - need data-test-subj attributes - cy.get(OVERVIEW_SEVERITY).should('have.text', alert.severity); + cy.get(OVERVIEW_STATUS).should('have.text', 'open'); + cy.get(OVERVIEW_RULE).should('have.text', alert.rule); + cy.get(OVERVIEW_SEVERITY).contains(alert.severity, { matchCase: false }); + cy.get(OVERVIEW_RISK_SCORE).should('have.text', alert.riskScore); cy.get(OVERVIEW_HOST_NAME).should('have.text', alert.hostName); cy.get(OVERVIEW_THRESHOLD_COUNT).should('have.text', alert.thresholdCount); cy.get(OVERVIEW_THRESHOLD_VALUE).should('have.text', alert.hostName); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/overview/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/overview/__snapshots__/index.test.tsx.snap index 5e4a17dcb030be..16c9136d55e694 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/overview/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/overview/__snapshots__/index.test.tsx.snap @@ -107,6 +107,7 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = ` >
47
@@ -262,6 +264,7 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = ` >