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={ + ( + + {chunks} + + ), + docsLink: ( + + } + /> + ), + }} + /> + } + /> +
+
+)); +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.