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"
And an error when you go to alerts -> rules and connectors:
Now you see it complete running:
And you see it without an error.
### 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;
+}
+
+`;
+
+exports[`NoDataCard props extends EuiCardProps 1`] = `
+.emotion-0 {
+ max-width: 400px;
+}
+
+
`;
+exports[`NoDataCard props isDisabled 1`] = `
+.emotion-0 {
+ max-width: 400px;
+}
+
+
+`;
+
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