diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/images/illustration_search_analytics.svg b/x-pack/solutions/security/plugins/security_solution/public/common/images/illustration_search_analytics.svg
new file mode 100644
index 0000000000000..a59379604606e
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/images/illustration_search_analytics.svg
@@ -0,0 +1,36 @@
+
diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.test.tsx
index 3ec9b6f864d4e..956cf9582f480 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.test.tsx
@@ -13,6 +13,20 @@ import { TestProviders } from '../../common/mock';
import { useSourcererDataView } from '../../sourcerer/containers';
import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features';
import { useDataView } from '../../data_view_manager/hooks/use_data_view';
+import { useEntityStoreStatus } from '../components/entity_store/hooks/use_entity_store';
+
+jest.mock('../../common/components/links/link_props', () => {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const mockReact = require('react');
+ return {
+ withSecuritySolutionLink:
+ (WrappedComponent: React.ComponentType>) =>
+ (props: Record) =>
+ mockReact.createElement(WrappedComponent, { ...props, href: '/mocked' }),
+ useGetSecuritySolutionLinkProps: jest.fn(() => () => ({ href: '/mocked', onClick: jest.fn() })),
+ useSecuritySolutionLinkProps: jest.fn(() => ({ href: '/mocked', onClick: jest.fn() })),
+ };
+});
jest.mock('../../common/components/link_to', () => ({
useGetSecuritySolutionUrl:
@@ -97,6 +111,12 @@ jest.mock('../components/home/use_entity_store_data_view', () => ({
})),
}));
+jest.mock('../components/entity_store/hooks/use_entity_store', () => ({
+ useEntityStoreStatus: jest.fn(() => ({
+ data: { status: 'running', engines: [] },
+ })),
+}));
+
// useEntityURLState is already mocked inside the entities_table mock above
jest.mock('../../common/hooks/use_space_id', () => ({
@@ -112,6 +132,7 @@ jest.mock('@kbn/expandable-flyout', () => ({
const mockUseSourcererDataView = useSourcererDataView as jest.Mock;
const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock;
const mockUseDataView = useDataView as jest.Mock;
+const mockUseEntityStoreStatus = useEntityStoreStatus as jest.Mock;
describe('EntityAnalyticsHomePage', () => {
beforeEach(() => {
@@ -132,6 +153,10 @@ describe('EntityAnalyticsHomePage', () => {
dataView: { id: 'test', matchedIndices: ['index-1'] },
status: 'ready',
});
+
+ mockUseEntityStoreStatus.mockReturnValue({
+ data: { status: 'running', engines: [] },
+ });
});
it('renders the page title', () => {
@@ -240,4 +265,120 @@ describe('EntityAnalyticsHomePage', () => {
// EmptyPrompt should be rendered
expect(screen.queryByTestId('entityAnalyticsHomePage')).not.toBeInTheDocument();
});
+
+ it("renders entity store disabled empty prompt when status is 'not_installed'", () => {
+ mockUseEntityStoreStatus.mockReturnValue({
+ data: { status: 'not_installed', engines: [] },
+ });
+
+ render(
+
+
+ ,
+ { wrapper: TestProviders }
+ );
+
+ expect(screen.getByTestId('entityStoreDisabledEmptyPrompt')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Entity analytics' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Enable Entity analytics' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Read the docs/ })).toBeInTheDocument();
+ expect(screen.queryByTestId('entityAnalyticsHomePage')).not.toBeInTheDocument();
+ });
+
+ it("renders entity store disabled empty prompt when status is 'stopped'", () => {
+ mockUseEntityStoreStatus.mockReturnValue({
+ data: { status: 'stopped', engines: [] },
+ });
+
+ render(
+
+
+ ,
+ { wrapper: TestProviders }
+ );
+
+ expect(screen.getByTestId('entityStoreDisabledEmptyPrompt')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Entity analytics' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Enable Entity analytics' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Read the docs/ })).toBeInTheDocument();
+ expect(screen.queryByTestId('entityAnalyticsHomePage')).not.toBeInTheDocument();
+ });
+
+ it("does not render disabled empty prompt when status is 'running'", () => {
+ mockUseEntityStoreStatus.mockReturnValue({
+ data: { status: 'running', engines: [] },
+ });
+
+ render(
+
+
+ ,
+ { wrapper: TestProviders }
+ );
+
+ expect(screen.queryByTestId('entityStoreDisabledEmptyPrompt')).not.toBeInTheDocument();
+ expect(screen.getByTestId('entityAnalyticsHomePage')).toBeInTheDocument();
+ });
+
+ it("does not render disabled empty prompt when status is 'installing'", () => {
+ mockUseEntityStoreStatus.mockReturnValue({
+ data: { status: 'installing', engines: [] },
+ });
+
+ render(
+
+
+ ,
+ { wrapper: TestProviders }
+ );
+
+ expect(screen.queryByTestId('entityStoreDisabledEmptyPrompt')).not.toBeInTheDocument();
+ expect(screen.getByTestId('entityAnalyticsHomePage')).toBeInTheDocument();
+ });
+
+ it('disabled empty prompt footer renders a Read the docs link to the entity analytics docs', () => {
+ mockUseEntityStoreStatus.mockReturnValue({
+ data: { status: 'not_installed', engines: [] },
+ });
+
+ render(
+
+
+ ,
+ { wrapper: TestProviders }
+ );
+
+ expect(screen.getByText('Want to learn more?', { exact: false })).toBeInTheDocument();
+ const docsLink = screen.getByRole('link', { name: /Read the docs/ });
+ expect(docsLink).toBeInTheDocument();
+ expect(docsLink).toHaveAttribute('href', expect.stringContaining('entity-risk-scoring'));
+ expect(docsLink).toHaveAttribute('target', '_blank');
+ });
+
+ it('indicesExist=false still wins over entity store disabled state', () => {
+ mockUseSourcererDataView.mockReturnValue({
+ indicesExist: false,
+ loading: false,
+ sourcererDataView: { id: 'test', matchedIndices: [] },
+ });
+
+ mockUseDataView.mockReturnValue({
+ dataView: { id: 'test', matchedIndices: [] },
+ status: 'ready',
+ });
+
+ mockUseEntityStoreStatus.mockReturnValue({
+ data: { status: 'not_installed', engines: [] },
+ });
+
+ render(
+
+
+ ,
+ { wrapper: TestProviders }
+ );
+
+ expect(screen.queryByTestId('entityStoreDisabledEmptyPrompt')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('entityAnalyticsHomePage')).not.toBeInTheDocument();
+ });
});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.tsx
index c362b31120c8f..dfe6943457b0a 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.tsx
@@ -48,6 +48,8 @@ import {
type URLQuery,
} from '../components/home/entities_table';
import { DynamicRiskLevelPanel } from '../components/home/dynamic_risk_level_panel';
+import { useEntityStoreStatus } from '../components/entity_store/hooks/use_entity_store';
+import { EntityStoreDisabledEmptyPrompt } from './entity_store_disabled_empty_prompt';
import { useGetSecuritySolutionUrl } from '../../common/components/link_to';
import { TabId } from './entity_analytics_management_page';
import { TopThreatHuntingLeads } from '../components/threat_hunting/top_threat_hunting_leads';
@@ -147,6 +149,11 @@ export const EntityAnalyticsHomePage = () => {
const isXlScreen = useIsWithinBreakpoints(['l', 'xl']);
const showEmptyPrompt = !indicesExist;
+ const { data: entityStoreStatusData } = useEntityStoreStatus();
+ const entityStoreDisabled =
+ entityStoreStatusData?.status === 'not_installed' ||
+ entityStoreStatusData?.status === 'stopped';
+
const handleOpenFlyout = useCallback(() => setIsFlyoutOpen(true), []);
const handleCloseFlyout = useCallback(() => setIsFlyoutOpen(false), []);
@@ -173,6 +180,10 @@ export const EntityAnalyticsHomePage = () => {
return ;
}
+ if (entityStoreDisabled) {
+ return ;
+ }
+
return (
<>
diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_store_disabled_empty_prompt.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_store_disabled_empty_prompt.tsx
new file mode 100644
index 0000000000000..358022fa1a41a
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_store_disabled_empty_prompt.tsx
@@ -0,0 +1,104 @@
+/*
+ * Copyright 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 { css } from '@emotion/react';
+import { EuiEmptyPrompt, EuiFlexGroup, EuiImage, EuiText } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { SecurityPageName } from '../../../common/constants';
+import { SecuritySolutionLinkButton } from '../../common/components/links';
+import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper';
+import { HeaderPage } from '../../common/components/header_page';
+import { EntityAnalyticsLearnMoreLink } from '../components/entity_analytics_learn_more_link';
+import illustrationSearchAnalytics from '../../common/images/illustration_search_analytics.svg';
+
+export const EntityStoreDisabledEmptyPrompt = React.memo(() => (
+
+
+ }
+ />
+
+ }
+ layout="horizontal"
+ style={{ maxWidth: 624 }}
+ title={
+
+
+
+ }
+ actions={
+
+
+
+ }
+ footer={
+
+
+));
+EntityStoreDisabledEmptyPrompt.displayName = 'EntityStoreDisabledEmptyPrompt';
diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entities_table.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entities_table.cy.ts
index 6a9dc7b0dc278..b1ec733b3ccb0 100644
--- a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entities_table.cy.ts
+++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entities_table.cy.ts
@@ -7,7 +7,10 @@
import { login } from '../../../tasks/login';
import { visit } from '../../../tasks/navigation';
-import { setGrouping } from '../../../tasks/entity_analytics/entity_analytics_home';
+import {
+ interceptEntityStoreStatus,
+ setGrouping,
+} from '../../../tasks/entity_analytics/entity_analytics_home';
import { ENTITY_ANALYTICS_HOME_PAGE_URL } from '../../../urls/navigation';
import {
PAGE_TITLE,
@@ -45,6 +48,7 @@ describe(
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'entityAnalyticsNewHomePageEnabled',
])}`,
+ '--uiSettings.overrides.securitySolution:entityStoreEnableV2=true',
],
},
},
@@ -55,9 +59,11 @@ describe(
});
beforeEach(() => {
+ interceptEntityStoreStatus('running');
login();
setGrouping(['none']);
visit(ENTITY_ANALYTICS_HOME_PAGE_URL);
+ cy.wait('@entityStoreStatus', { timeout: 20000 });
waitForTableToLoad();
});
@@ -182,15 +188,18 @@ describe(
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'entityAnalyticsNewHomePageEnabled',
])}`,
+ '--uiSettings.overrides.securitySolution:entityStoreEnableV2=true',
],
},
},
},
() => {
beforeEach(() => {
+ interceptEntityStoreStatus('running');
login();
setGrouping(['none']);
visit(ENTITY_ANALYTICS_HOME_PAGE_URL);
+ cy.wait('@entityStoreStatus', { timeout: 20000 });
cy.get(PAGE_TITLE).should('exist');
});
diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entities_table_grouping.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entities_table_grouping.cy.ts
index 245831ca08afc..40a39381f8237 100644
--- a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entities_table_grouping.cy.ts
+++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entities_table_grouping.cy.ts
@@ -12,6 +12,7 @@ import {
waitForGroupingTable,
interceptEntityStoreSearch,
selectGroupingOption,
+ interceptEntityStoreStatus,
} from '../../../tasks/entity_analytics/entity_analytics_home';
import { ENTITY_ANALYTICS_HOME_PAGE_URL } from '../../../urls/navigation';
import {
@@ -40,6 +41,7 @@ describe(
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'entityAnalyticsNewHomePageEnabled',
])}`,
+ '--uiSettings.overrides.securitySolution:entityStoreEnableV2=true',
],
},
},
@@ -56,10 +58,12 @@ describe(
describe('Group by Resolution', () => {
beforeEach(() => {
login();
+ interceptEntityStoreStatus('running');
interceptEntityStoreSearch();
visit(ENTITY_ANALYTICS_HOME_PAGE_URL);
cy.get(PAGE_TITLE).should('exist');
// Resolution is the default grouping
+ cy.wait('@entityStoreStatus', { timeout: 20000 });
cy.wait('@entityStoreSearch');
waitForGroupingTable();
});
@@ -108,9 +112,11 @@ describe(
beforeEach(() => {
login();
setGrouping(['entity.EngineMetadata.Type']);
+ interceptEntityStoreStatus('running');
interceptEntityStoreSearch();
visit(ENTITY_ANALYTICS_HOME_PAGE_URL);
cy.get(PAGE_TITLE).should('exist');
+ cy.wait('@entityStoreStatus', { timeout: 20000 });
cy.wait('@entityStoreSearch');
waitForGroupingTable();
});
@@ -142,9 +148,11 @@ describe(
beforeEach(() => {
login();
setGrouping(['entity.relationships.resolution.resolved_to']);
+ interceptEntityStoreStatus('running');
interceptEntityStoreSearch();
visit(ENTITY_ANALYTICS_HOME_PAGE_URL);
cy.get(PAGE_TITLE).should('exist');
+ cy.wait('@entityStoreStatus', { timeout: 20000 });
cy.wait('@entityStoreSearch');
waitForGroupingTable();
});
diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entity_analytics_home_page.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entity_analytics_home_page.cy.ts
index ff98a491e09d6..43d7ddbb9a8d3 100644
--- a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entity_analytics_home_page.cy.ts
+++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_analytics_home/entity_analytics_home_page.cy.ts
@@ -14,7 +14,9 @@ import {
ANOMALIES_PLACEHOLDER_PANEL,
ENTITIES_TABLE_GRID,
TIMELINE_ACTION,
+ ENTITY_STORE_DISABLED_EMPTY_PROMPT,
} from '../../../screens/entity_analytics/entity_analytics_home';
+import { interceptEntityStoreStatus } from '../../../tasks/entity_analytics/entity_analytics_home';
describe(
'Entity Analytics page',
@@ -26,6 +28,7 @@ describe(
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'entityAnalyticsNewHomePageEnabled',
])}`,
+ '--uiSettings.overrides.securitySolution:entityStoreEnableV2=true',
],
},
},
@@ -36,6 +39,7 @@ describe(
});
beforeEach(() => {
+ interceptEntityStoreStatus('running');
login();
// Set grouping to "none" so the flat EntitiesDataTable renders.
// Default "Resolution" grouping renders GroupWrapper, which doesn't
@@ -47,7 +51,7 @@ describe(
)
);
visit(ENTITY_ANALYTICS_HOME_PAGE_URL);
- cy.url().should('include', ENTITY_ANALYTICS_HOME_PAGE_URL);
+ cy.wait('@entityStoreStatus', { timeout: 20000 });
});
after(() => {
@@ -96,3 +100,33 @@ describe(
});
}
);
+
+describe(
+ 'Entity Analytics page - Disabled state',
+ {
+ tags: ['@ess'],
+ env: {
+ ftrConfig: {
+ kbnServerArgs: [
+ `--xpack.securitySolution.enableExperimental=${JSON.stringify([
+ 'entityAnalyticsNewHomePageEnabled',
+ ])}`,
+ '--uiSettings.overrides.securitySolution:entityStoreEnableV2=true',
+ ],
+ },
+ },
+ },
+ () => {
+ beforeEach(() => {
+ interceptEntityStoreStatus('not_installed');
+ login();
+ visit(ENTITY_ANALYTICS_HOME_PAGE_URL);
+ cy.wait('@entityStoreStatus', { timeout: 20000 });
+ cy.contains('h1', 'Entity analytics').should('exist');
+ });
+
+ it('displays the entity store disabled prompt', () => {
+ cy.get(ENTITY_STORE_DISABLED_EMPTY_PROMPT).should('exist');
+ });
+ }
+);
diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/entity_analytics/entity_analytics_home.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/entity_analytics/entity_analytics_home.ts
index e6dbaa7505d45..49b6b364faf84 100644
--- a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/entity_analytics/entity_analytics_home.ts
+++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/entity_analytics/entity_analytics_home.ts
@@ -13,6 +13,8 @@ export const ANOMALIES_PLACEHOLDER_PANEL = '[data-test-subj="recent-anomalies-pa
export const ENTITIES_TABLE_GRID = '[data-test-subj="entity-analytics-test-subj-grid-wrapper"]';
export const ENTITIES_TABLE_EMPTY = '[data-test-subj="entity-analytics-empty-state"]';
+export const ENTITY_STORE_DISABLED_EMPTY_PROMPT =
+ '[data-test-subj="entityStoreDisabledEmptyPrompt"]';
export const DATAGRID_HEADER = '[data-test-subj="dataGridHeader"]';
export const DATAGRID_COLUMN_SELECTOR = '[data-test-subj="dataGridColumnSelectorButton"]';
diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/entity_analytics/entity_analytics_home.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/entity_analytics/entity_analytics_home.ts
index 43172ae1ec4ba..616e0540d6ea5 100644
--- a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/entity_analytics/entity_analytics_home.ts
+++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/entity_analytics/entity_analytics_home.ts
@@ -67,6 +67,16 @@ export const interceptEntityStoreSearch = () => {
cy.intercept('POST', ENTITY_STORE_SEARCH_API).as('entityStoreSearch');
};
+export const interceptEntityStoreStatus = (status: 'running' | 'not_installed') => {
+ cy.intercept(
+ { method: 'GET', pathname: '/api/security/entity_store/status' },
+ {
+ statusCode: 200,
+ body: { status, engines: [] },
+ }
+ ).as('entityStoreStatus');
+};
+
/**
* Opens the grouping dropdown and selects the given option.
* Waits for in-flight search requests via API intercept before interacting.