diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/entity_store.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/entity_store.ts index 54f5415d24a35..f1afa13637bb8 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/api/entity_store.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/entity_store.ts @@ -5,6 +5,11 @@ * 2.0. */ import { useMemo } from 'react'; +import type { + GetEntityStoreStatusResponse, + InitEntityStoreRequestBody, + InitEntityStoreResponse, +} from '../../../common/api/entity_analytics/entity_store/enablement.gen'; import type { DeleteEntityEngineResponse, EntityType, @@ -20,7 +25,24 @@ export const useEntityStoreRoutes = () => { const http = useKibana().services.http; return useMemo(() => { - const initEntityStore = async (entityType: EntityType) => { + const enableEntityStore = async ( + options: InitEntityStoreRequestBody = { fieldHistoryLength: 10 } + ) => { + return http.fetch('/api/entity_store/enable', { + method: 'POST', + version: API_VERSIONS.public.v1, + body: JSON.stringify(options), + }); + }; + + const getEntityStoreStatus = async () => { + return http.fetch('/api/entity_store/status', { + method: 'GET', + version: API_VERSIONS.public.v1, + }); + }; + + const initEntityEngine = async (entityType: EntityType) => { return http.fetch(`/api/entity_store/engines/${entityType}/init`, { method: 'POST', version: API_VERSIONS.public.v1, @@ -28,7 +50,7 @@ export const useEntityStoreRoutes = () => { }); }; - const stopEntityStore = async (entityType: EntityType) => { + const stopEntityEngine = async (entityType: EntityType) => { return http.fetch(`/api/entity_store/engines/${entityType}/stop`, { method: 'POST', version: API_VERSIONS.public.v1, @@ -59,8 +81,10 @@ export const useEntityStoreRoutes = () => { }; return { - initEntityStore, - stopEntityStore, + enableEntityStore, + getEntityStoreStatus, + initEntityEngine, + stopEntityEngine, getEntityEngine, deleteEntityEngine, listEntityEngines, diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_engine_status.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_engine_status.ts index a9c9dc0939b03..14ab3fc7ca15b 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_engine_status.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_engine_status.ts @@ -38,7 +38,7 @@ export const useIsNewRiskScoreModuleInstalled = (): RiskScoreModuleStatus => { return { isLoading: false, installed: !!riskEngineStatus?.isNewRiskScoreModuleInstalled }; }; -interface RiskEngineStatus extends RiskEngineStatusResponse { +export interface RiskEngineStatus extends RiskEngineStatusResponse { isUpdateAvailable: boolean; isNewRiskScoreModuleInstalled: boolean; isNewRiskScoreModuleAvailable: boolean; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_enablement_panel.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_enablement_panel.tsx new file mode 100644 index 0000000000000..8d0426fd99ceb --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_enablement_panel.tsx @@ -0,0 +1,198 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { + EuiCallOut, + EuiPanel, + EuiEmptyPrompt, + EuiLoadingLogo, + EuiToolTip, + EuiButton, + EuiImage, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { UseQueryResult } from '@tanstack/react-query'; +import type { GetEntityStoreStatusResponse } from '../../../../../common/api/entity_analytics/entity_store/enablement.gen'; +import type { StoreStatus } from '../../../../../common/api/entity_analytics'; +import { RiskEngineStatusEnum } from '../../../../../common/api/entity_analytics'; +import { useInitRiskEngineMutation } from '../../../api/hooks/use_init_risk_engine_mutation'; +import { useEnableEntityStoreMutation } from '../hooks/use_entity_store'; +import { + ENABLEMENT_INITIALIZING_RISK_ENGINE, + ENABLEMENT_INITIALIZING_ENTITY_STORE, + ENABLE_ALL_TITLE, + ENABLEMENT_DESCRIPTION_BOTH, + ENABLE_RISK_SCORE_TITLE, + ENABLEMENT_DESCRIPTION_RISK_ENGINE_ONLY, + ENABLE_ENTITY_STORE_TITLE, + ENABLEMENT_DESCRIPTION_ENTITY_STORE_ONLY, +} from '../translations'; +import type { Enablements } from './enablement_modal'; +import { EntityStoreEnablementModal } from './enablement_modal'; +import dashboardEnableImg from '../../../images/entity_store_dashboard.png'; +import type { RiskEngineStatus } from '../../../api/hooks/use_risk_engine_status'; + +interface EnableEntityStorePanelProps { + state: { + riskEngine: UseQueryResult; + entityStore: UseQueryResult; + }; +} + +export const EnablementPanel: React.FC = ({ state }) => { + const riskEngineStatus = state.riskEngine.data?.risk_engine_status; + const entityStoreStatus = state.entityStore.data?.status; + + const [modal, setModalState] = useState({ visible: false }); + const [riskEngineInitializing, setRiskEngineInitializing] = useState(false); + + const initRiskEngine = useInitRiskEngineMutation(); + const storeEnablement = useEnableEntityStoreMutation(); + + const enableEntityStore = useCallback( + (enable: Enablements) => () => { + if (enable.riskScore) { + const options = { + onSuccess: () => { + setRiskEngineInitializing(false); + if (enable.entityStore) { + storeEnablement.mutate(); + } + }, + }; + setRiskEngineInitializing(true); + initRiskEngine.mutate(undefined, options); + setModalState({ visible: false }); + return; + } + + if (enable.entityStore) { + storeEnablement.mutate(); + setModalState({ visible: false }); + } + }, + [storeEnablement, initRiskEngine] + ); + + if (storeEnablement.error) { + return ( + <> + + } + color="danger" + iconType="error" + > +

{storeEnablement.error.body.message}

+
+ + ); + } + + if (riskEngineInitializing) { + return ( + + } + title={

{ENABLEMENT_INITIALIZING_RISK_ENGINE}

} + /> +
+ ); + } + + if (entityStoreStatus === 'installing' || storeEnablement.isLoading) { + return ( + + } + title={

{ENABLEMENT_INITIALIZING_ENTITY_STORE}

} + body={ +

+ +

+ } + /> +
+ ); + } + + if ( + riskEngineStatus !== RiskEngineStatusEnum.NOT_INSTALLED && + (entityStoreStatus === 'running' || entityStoreStatus === 'stopped') + ) { + return null; + } + + const [title, body] = getEnablementTexts(entityStoreStatus, riskEngineStatus); + return ( + <> + {title}} + body={

{body}

} + actions={ + + setModalState({ visible: true })} + data-test-subj={`entityStoreEnablementButton`} + > + + + + } + icon={} + data-test-subj="entityStoreEnablementPanel" + /> + + setModalState({ visible })} + enableStore={enableEntityStore} + riskScore={{ + disabled: riskEngineStatus !== RiskEngineStatusEnum.NOT_INSTALLED, + checked: riskEngineStatus === RiskEngineStatusEnum.NOT_INSTALLED, + }} + entityStore={{ + disabled: entityStoreStatus === 'running', + checked: entityStoreStatus === 'not_installed', + }} + /> + + ); +}; + +const getEnablementTexts = ( + entityStoreStatus?: StoreStatus, + riskEngineStatus?: RiskEngineStatus['risk_engine_status'] +): [string, string] => { + if ( + (entityStoreStatus === 'not_installed' || entityStoreStatus === 'stopped') && + riskEngineStatus === RiskEngineStatusEnum.NOT_INSTALLED + ) { + return [ENABLE_ALL_TITLE, ENABLEMENT_DESCRIPTION_BOTH]; + } + + if (riskEngineStatus === RiskEngineStatusEnum.NOT_INSTALLED) { + return [ENABLE_RISK_SCORE_TITLE, ENABLEMENT_DESCRIPTION_RISK_ENGINE_ONLY]; + } + + return [ENABLE_ENTITY_STORE_TITLE, ENABLEMENT_DESCRIPTION_ENTITY_STORE_ONLY]; +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_entity_store_panels.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_entity_store_panels.tsx new file mode 100644 index 0000000000000..0870d37a6b2b5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_entity_store_panels.tsx @@ -0,0 +1,95 @@ +/* + * 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 { + EuiEmptyPrompt, + EuiLoadingSpinner, + EuiFlexItem, + EuiFlexGroup, + EuiPanel, + EuiCallOut, +} from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; +import { RiskEngineStatusEnum } from '../../../../../common/api/entity_analytics'; +import { RiskScoreEntity } from '../../../../../common/search_strategy'; +import { EntitiesList } from '../entities_list'; +import { useEntityStoreStatus } from '../hooks/use_entity_store'; +import { EntityAnalyticsRiskScores } from '../../entity_analytics_risk_score'; +import { useRiskEngineStatus } from '../../../api/hooks/use_risk_engine_status'; + +import { EnablementPanel } from './dashboard_enablement_panel'; + +const EntityStoreDashboardPanelsComponent = () => { + const riskEngineStatus = useRiskEngineStatus(); + const storeStatusQuery = useEntityStoreStatus({}); + + const callouts = (storeStatusQuery.data?.engines ?? []) + .filter((engine) => engine.status === 'error') + .map((engine) => { + const err = engine.error as { + message: string; + }; + return ( + + } + color="danger" + iconType="error" + > +

{err?.message}

+
+ ); + }); + + if (storeStatusQuery.status === 'loading') { + return ( + + } /> + + ); + } + + return ( + + {storeStatusQuery.status === 'error' ? ( + callouts + ) : ( + + )} + + {riskEngineStatus.data?.risk_engine_status !== RiskEngineStatusEnum.NOT_INSTALLED && ( + <> + + + + + + + + )} + {storeStatusQuery.data?.status !== 'not_installed' && + storeStatusQuery.data?.status !== 'installing' && ( + + + + )} + + ); +}; + +export const EntityStoreDashboardPanels = React.memo(EntityStoreDashboardPanelsComponent); +EntityStoreDashboardPanels.displayName = 'EntityStoreDashboardPanels'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_panels.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_panels.tsx deleted file mode 100644 index d70eb9fe34b51..0000000000000 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/dashboard_panels.tsx +++ /dev/null @@ -1,313 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React, { useState } from 'react'; -import { - EuiEmptyPrompt, - EuiToolTip, - EuiButton, - EuiLoadingSpinner, - EuiFlexItem, - EuiFlexGroup, - EuiLoadingLogo, - EuiPanel, - EuiImage, - EuiCallOut, -} from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n-react'; -import { RiskEngineStatusEnum } from '../../../../../common/api/entity_analytics'; -import { RiskScoreEntity } from '../../../../../common/search_strategy'; - -import { EntitiesList } from '../entities_list'; - -import { useEntityStoreEnablement } from '../hooks/use_entity_store'; -import { EntityStoreEnablementModal, type Enablements } from './enablement_modal'; - -import { EntityAnalyticsRiskScores } from '../../entity_analytics_risk_score'; -import { useInitRiskEngineMutation } from '../../../api/hooks/use_init_risk_engine_mutation'; -import { useEntityEngineStatus } from '../hooks/use_entity_engine_status'; - -import dashboardEnableImg from '../../../images/entity_store_dashboard.png'; -import { - ENABLEMENT_DESCRIPTION_BOTH, - ENABLEMENT_DESCRIPTION_ENTITY_STORE_ONLY, - ENABLEMENT_DESCRIPTION_RISK_ENGINE_ONLY, - ENABLEMENT_INITIALIZING_ENTITY_STORE, - ENABLEMENT_INITIALIZING_RISK_ENGINE, - ENABLE_ALL_TITLE, - ENABLE_ENTITY_STORE_TITLE, - ENABLE_RISK_SCORE_TITLE, -} from '../translations'; -import { useRiskEngineStatus } from '../../../api/hooks/use_risk_engine_status'; - -const EntityStoreDashboardPanelsComponent = () => { - const [modal, setModalState] = useState({ visible: false }); - const [riskEngineInitializing, setRiskEngineInitializing] = useState(false); - - const entityStore = useEntityEngineStatus(); - const riskEngineStatus = useRiskEngineStatus(); - - const { enable: enableStore, query } = useEntityStoreEnablement(); - - const { mutate: initRiskEngine } = useInitRiskEngineMutation(); - - const callouts = entityStore.errors.map((err) => ( - - } - color="danger" - iconType="error" - > -

{err?.message}

-
- )); - - const enableEntityStore = (enable: Enablements) => () => { - setModalState({ visible: false }); - if (enable.riskScore) { - const options = { - onSuccess: () => { - setRiskEngineInitializing(false); - if (enable.entityStore) { - enableStore(); - } - }, - }; - setRiskEngineInitializing(true); - initRiskEngine(undefined, options); - return; - } - - if (enable.entityStore) { - enableStore(); - } - }; - - if (query.error) { - return ( - <> - - } - color="danger" - iconType="error" - > -

{(query.error as { body: { message: string } }).body.message}

-
- {callouts} - - ); - } - - if (entityStore.status === 'loading') { - return ( - - } - title={

{ENABLEMENT_INITIALIZING_ENTITY_STORE}

} - /> -
- ); - } - - if (entityStore.status === 'installing') { - return ( - - } - title={

{ENABLEMENT_INITIALIZING_ENTITY_STORE}

} - body={ -

- -

- } - /> -
- ); - } - - // TODO Rename variable because the Risk score could be installed but disabled - const isRiskScoreAvailable = - riskEngineStatus.data && - riskEngineStatus.data.risk_engine_status !== RiskEngineStatusEnum.NOT_INSTALLED; - - return ( - - {entityStore.status === 'error' && isRiskScoreAvailable && ( - <> - {callouts} - - - - - - - - )} - {entityStore.status === 'error' && !isRiskScoreAvailable && ( - <> - {callouts} - - setModalState({ visible: true })} - loadingRiskEngine={riskEngineInitializing} - enablements="riskScore" - /> - - - )} - {entityStore.status === 'enabled' && isRiskScoreAvailable && ( - <> - - - - - - - - - - - )} - {entityStore.status === 'enabled' && !isRiskScoreAvailable && ( - <> - - setModalState({ visible: true })} - loadingRiskEngine={riskEngineInitializing} - enablements="riskScore" - /> - - - - - - - )} - - {(entityStore.status === 'not_installed' || entityStore.status === 'stopped') && - !isRiskScoreAvailable && ( - // TODO: Move modal inside EnableEntityStore component, eliminating the onEnable prop in favour of forwarding the riskScoreEnabled status - setModalState({ visible: true })} - loadingRiskEngine={riskEngineInitializing} - /> - )} - - {(entityStore.status === 'not_installed' || entityStore.status === 'stopped') && - isRiskScoreAvailable && ( - <> - - - setModalState({ - visible: true, - }) - } - /> - - - - - - - - - )} - - setModalState({ visible })} - enableStore={enableEntityStore} - riskScore={{ disabled: isRiskScoreAvailable, checked: !isRiskScoreAvailable }} - entityStore={{ - disabled: entityStore.status === 'enabled', - checked: entityStore.status !== 'enabled', - }} - /> - - ); -}; - -interface EnableEntityStoreProps { - onEnable: () => void; - enablements: 'store' | 'riskScore' | 'both'; - loadingRiskEngine?: boolean; -} - -export const EnableEntityStore: React.FC = ({ - onEnable, - enablements, - loadingRiskEngine, -}) => { - const title = - enablements === 'store' - ? ENABLE_ENTITY_STORE_TITLE - : enablements === 'riskScore' - ? ENABLE_RISK_SCORE_TITLE - : ENABLE_ALL_TITLE; - - const body = - enablements === 'store' - ? ENABLEMENT_DESCRIPTION_ENTITY_STORE_ONLY - : enablements === 'riskScore' - ? ENABLEMENT_DESCRIPTION_RISK_ENGINE_ONLY - : ENABLEMENT_DESCRIPTION_BOTH; - - if (loadingRiskEngine) { - return ( - - } - title={

{ENABLEMENT_INITIALIZING_RISK_ENGINE}

} - /> -
- ); - } - return ( - {title}} - body={

{body}

} - actions={ - - - - - - } - icon={} - /> - ); -}; - -export const EntityStoreDashboardPanels = React.memo(EntityStoreDashboardPanelsComponent); -EntityStoreDashboardPanels.displayName = 'EntityStoreDashboardPanels'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx index f2953e8102b90..e3d01ec82907e 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx @@ -90,7 +90,7 @@ export const EntityStoreEnablementModal: React.FC ); return ( - toggle(false)}> + toggle(false)} data-test-subj="entityStoreEnablementModal"> setEnablements((prev) => ({ ...prev, riskScore: !prev.riskScore }))} + data-test-subj="enablementRiskScoreSwitch" /> {!riskEnginePrivileges.isLoading && !riskEnginePrivileges.hasAllRequiredPrivileges && ( @@ -145,6 +146,7 @@ export const EntityStoreEnablementModal: React.FC setEnablements((prev) => ({ ...prev, entityStore: !prev.entityStore })) } + data-test-subj="enablementEntityStoreSwitch" /> @@ -173,6 +175,7 @@ export const EntityStoreEnablementModal: React.FC ['refetchInterval']; -} - -interface EngineError { - message: string; -} - -export const useEntityEngineStatus = (opts: Options = {}) => { - // QUESTION: Maybe we should have an `EnablementStatus` API route for this? - const { listEntityEngines } = useEntityStoreRoutes(); - - const { isLoading, data } = useQuery({ - queryKey: [ENTITY_STORE_ENGINE_STATUS], - queryFn: () => listEntityEngines(), - refetchInterval: opts.polling, - enabled: !opts.disabled, - }); - - const status = (() => { - if (data?.count === 0) { - return 'not_installed'; - } - - if (data?.engines?.some((engine) => engine.status === 'error')) { - return 'error'; - } - - if (data?.engines?.every((engine) => engine.status === 'stopped')) { - return 'stopped'; - } - - if (data?.engines?.some((engine) => engine.status === 'installing')) { - return 'installing'; - } - - if (isLoading) { - return 'loading'; - } - - if (!data) { - return 'error'; - } - - return 'enabled'; - })(); - - const errors = (data?.engines - ?.filter((engine) => engine.status === 'error') - .map((engine) => engine.error) ?? []) as EngineError[]; - - return { - status, - errors, - }; -}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_store.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_store.ts index 8aefbe2b44af1..b27b5b4cdf26a 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_store.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/hooks/use_entity_store.ts @@ -5,10 +5,13 @@ * 2.0. */ -import type { UseMutationOptions } from '@tanstack/react-query'; +import type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useState } from 'react'; +import type { + GetEntityStoreStatusResponse, + InitEntityStoreResponse, +} from '../../../../../common/api/entity_analytics/entity_store/enablement.gen'; import { useKibana } from '../../../../common/lib/kibana/kibana_react'; import type { DeleteEntityEngineResponse, @@ -16,139 +19,98 @@ import type { StopEntityEngineResponse, } from '../../../../../common/api/entity_analytics'; import { useEntityStoreRoutes } from '../../../api/entity_store'; -import { ENTITY_STORE_ENGINE_STATUS, useEntityEngineStatus } from './use_entity_engine_status'; import { EntityEventTypes } from '../../../../common/lib/telemetry'; -const ENTITY_STORE_ENABLEMENT_INIT = 'ENTITY_STORE_ENABLEMENT_INIT'; - -export const useEntityStoreEnablement = () => { - const [polling, setPolling] = useState(false); - const { telemetry } = useKibana().services; +const ENTITY_STORE_STATUS = ['GET', 'ENTITY_STORE_STATUS']; - useEntityEngineStatus({ - disabled: !polling, - polling: (data) => { - const shouldStopPolling = - data?.engines && - data.engines.length > 0 && - data.engines.every((engine) => engine.status === 'started'); +interface ResponseError { + body: { message: string }; +} +export const useEntityStoreStatus = (options: UseQueryOptions) => { + const { getEntityStoreStatus } = useEntityStoreRoutes(); - if (shouldStopPolling) { - setPolling(false); - return false; + const query = useQuery(ENTITY_STORE_STATUS, getEntityStoreStatus, { + refetchInterval: (data) => { + if (data?.status === 'installing') { + return 5000; } - return 5000; + return false; }, + ...options, }); - - const { initEntityStore } = useEntityStoreRoutes(); - const { refetch: initialize, ...query } = useQuery({ - queryKey: [ENTITY_STORE_ENABLEMENT_INIT], - queryFn: async () => - initEntityStore('user').then((usr) => initEntityStore('host').then((host) => [usr, host])), - enabled: false, - }); - - const enable = useCallback(() => { - telemetry?.reportEvent(EntityEventTypes.EntityStoreDashboardInitButtonClicked, { - timestamp: new Date().toISOString(), - }); - return initialize().then(() => setPolling(true)); - }, [initialize, telemetry]); - - return { enable, query }; + return query; }; -export const INIT_ENTITY_ENGINE_STATUS_KEY = ['POST', 'INIT_ENTITY_ENGINE']; - -export const useInvalidateEntityEngineStatusQuery = () => { +export const ENABLE_STORE_STATUS_KEY = ['POST', 'ENABLE_ENTITY_STORE']; +export const useEnableEntityStoreMutation = (options?: UseMutationOptions<{}>) => { + const { telemetry } = useKibana().services; const queryClient = useQueryClient(); + const { enableEntityStore } = useEntityStoreRoutes(); - return useCallback(() => { - queryClient.invalidateQueries([ENTITY_STORE_ENGINE_STATUS], { - refetchType: 'active', - }); - }, [queryClient]); -}; - -export const useInitEntityEngineMutation = (options?: UseMutationOptions<{}>) => { - const { telemetry } = useKibana().services; - const invalidateEntityEngineStatusQuery = useInvalidateEntityEngineStatusQuery(); - const { initEntityStore } = useEntityStoreRoutes(); - return useMutation( + return useMutation( () => { telemetry?.reportEvent(EntityEventTypes.EntityStoreEnablementToggleClicked, { timestamp: new Date().toISOString(), action: 'start', }); - return initEntityStore('user').then((usr) => - initEntityStore('host').then((host) => [usr, host]) - ); + return enableEntityStore(); }, { + mutationKey: ENABLE_STORE_STATUS_KEY, + onSuccess: () => queryClient.refetchQueries(ENTITY_STORE_STATUS), ...options, - mutationKey: INIT_ENTITY_ENGINE_STATUS_KEY, - onSettled: (...args) => { - invalidateEntityEngineStatusQuery(); + } + ); +}; - if (options?.onSettled) { - options.onSettled(...args); - } - }, +export const INIT_ENTITY_ENGINE_STATUS_KEY = ['POST', 'INIT_ENTITY_ENGINE']; +export const useInitEntityEngineMutation = (options?: UseMutationOptions<{}>) => { + const queryClient = useQueryClient(); + + const { initEntityEngine } = useEntityStoreRoutes(); + return useMutation( + () => Promise.all([initEntityEngine('user'), initEntityEngine('host')]), + + { + mutationKey: INIT_ENTITY_ENGINE_STATUS_KEY, + onSuccess: () => queryClient.refetchQueries({ queryKey: ENTITY_STORE_STATUS }), + ...options, } ); }; export const STOP_ENTITY_ENGINE_STATUS_KEY = ['POST', 'STOP_ENTITY_ENGINE']; - export const useStopEntityEngineMutation = (options?: UseMutationOptions<{}>) => { const { telemetry } = useKibana().services; - const invalidateEntityEngineStatusQuery = useInvalidateEntityEngineStatusQuery(); - const { stopEntityStore } = useEntityStoreRoutes(); + const queryClient = useQueryClient(); + + const { stopEntityEngine } = useEntityStoreRoutes(); return useMutation( () => { telemetry?.reportEvent(EntityEventTypes.EntityStoreEnablementToggleClicked, { timestamp: new Date().toISOString(), action: 'stop', }); - return stopEntityStore('user').then((usr) => - stopEntityStore('host').then((host) => [usr, host]) - ); + return Promise.all([stopEntityEngine('user'), stopEntityEngine('host')]); }, { - ...options, mutationKey: STOP_ENTITY_ENGINE_STATUS_KEY, - onSettled: (...args) => { - invalidateEntityEngineStatusQuery(); - - if (options?.onSettled) { - options.onSettled(...args); - } - }, + onSuccess: () => queryClient.refetchQueries({ queryKey: ENTITY_STORE_STATUS }), + ...options, } ); }; export const DELETE_ENTITY_ENGINE_STATUS_KEY = ['POST', 'STOP_ENTITY_ENGINE']; - export const useDeleteEntityEngineMutation = (options?: UseMutationOptions<{}>) => { - const invalidateEntityEngineStatusQuery = useInvalidateEntityEngineStatusQuery(); + const queryClient = useQueryClient(); const { deleteEntityEngine } = useEntityStoreRoutes(); return useMutation( - () => - deleteEntityEngine('user', true).then((usr) => - deleteEntityEngine('host', true).then((host) => [usr, host]) - ), + () => Promise.all([deleteEntityEngine('user', true), deleteEntityEngine('host', true)]), { - ...options, mutationKey: DELETE_ENTITY_ENGINE_STATUS_KEY, - onSettled: (...args) => { - invalidateEntityEngineStatusQuery(); - - if (options?.onSettled) { - options.onSettled(...args); - } - }, + onSuccess: () => queryClient.refetchQueries({ queryKey: ENTITY_STORE_STATUS }), + ...options, } ); }; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_analytics_dashboard.tsx b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_analytics_dashboard.tsx index 5f034637dfec6..9c99ac1687171 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_analytics_dashboard.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_analytics_dashboard.tsx @@ -24,7 +24,7 @@ import { useHasSecurityCapability } from '../../helper_hooks'; import { EntityAnalyticsHeader } from '../components/entity_analytics_header'; import { EntityAnalyticsAnomalies } from '../components/entity_analytics_anomalies'; -import { EntityStoreDashboardPanels } from '../components/entity_store/components/dashboard_panels'; +import { EntityStoreDashboardPanels } from '../components/entity_store/components/dashboard_entity_store_panels'; import { EntityAnalyticsRiskScores } from '../components/entity_analytics_risk_score'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx index 84648d89f912d..7c00f61f62fbb 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx @@ -26,9 +26,12 @@ import { EuiToolTip, EuiBetaBadge, } from '@elastic/eui'; +import type { ReactNode } from 'react'; import React, { useCallback, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useEntityEngineStatus } from '../components/entity_store/hooks/use_entity_engine_status'; + +import type { SecurityAppError } from '@kbn/securitysolution-t-grid'; +import type { StoreStatus } from '../../../common/api/entity_analytics'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import { ASSET_CRITICALITY_INDEX_PATTERN } from '../../../common/entity_analytics/asset_criticality'; import { useKibana } from '../../common/lib/kibana'; @@ -37,16 +40,18 @@ import { useAssetCriticalityPrivileges } from '../components/asset_criticality/u import { useHasSecurityCapability } from '../../helper_hooks'; import { useDeleteEntityEngineMutation, - useInitEntityEngineMutation, + useEnableEntityStoreMutation, + useEntityStoreStatus, useStopEntityEngineMutation, } from '../components/entity_store/hooks/use_entity_store'; import { TECHNICAL_PREVIEW, TECHNICAL_PREVIEW_TOOLTIP } from '../../common/translations'; import { useEntityEnginePrivileges } from '../components/entity_store/hooks/use_entity_engine_privileges'; import { MissingPrivilegesCallout } from '../components/entity_store/components/missing_privileges_callout'; -const entityStoreEnabledStatuses = ['enabled']; -const switchDisabledStatuses = ['error', 'loading', 'installing']; -const entityStoreInstallingStatuses = ['installing', 'loading']; +const isSwitchDisabled = (status?: StoreStatus) => status === 'error' || status === 'installing'; +const isEntityStoreEnabled = (status?: StoreStatus) => status === 'running'; +const canDeleteEntityEngine = (status?: StoreStatus) => + !['not_installed', 'installing'].includes(status || ''); export const EntityStoreManagementPage = () => { const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics'); @@ -58,25 +63,9 @@ export const EntityStoreManagementPage = () => { } = useAssetCriticalityPrivileges('AssetCriticalityUploadPage'); const hasAssetCriticalityWritePermissions = assetCriticalityPrivileges?.has_write_permissions; - const [polling, setPolling] = useState(false); - const entityStoreStatus = useEntityEngineStatus({ - disabled: false, - polling: !polling - ? undefined - : (data) => { - const shouldStopPolling = - data?.engines && - data.engines.length > 0 && - data.engines.every((engine) => engine.status === 'started'); + const entityStoreStatus = useEntityStoreStatus({}); - if (shouldStopPolling) { - setPolling(false); - return false; - } - return 1000; - }, - }); - const initEntityEngineMutation = useInitEntityEngineMutation(); + const enableStoreMutation = useEnableEntityStoreMutation(); const stopEntityEngineMutation = useStopEntityEngineMutation(); const deleteEntityEngineMutation = useDeleteEntityEngineMutation({ onSuccess: () => { @@ -89,17 +78,16 @@ export const EntityStoreManagementPage = () => { const showClearModal = useCallback(() => setIsClearModalVisible(true), []); const onSwitchClick = useCallback(() => { - if (switchDisabledStatuses.includes(entityStoreStatus.status)) { + if (isSwitchDisabled(entityStoreStatus.data?.status)) { return; } - if (entityStoreEnabledStatuses.includes(entityStoreStatus.status)) { + if (isEntityStoreEnabled(entityStoreStatus.data?.status)) { stopEntityEngineMutation.mutate(); } else { - setPolling(true); - initEntityEngineMutation.mutate(); + enableStoreMutation.mutate(); } - }, [initEntityEngineMutation, stopEntityEngineMutation, entityStoreStatus]); + }, [entityStoreStatus.data?.status, stopEntityEngineMutation, enableStoreMutation]); const { data: privileges } = useEntityEnginePrivileges(); @@ -108,168 +96,33 @@ export const EntityStoreManagementPage = () => { return null; } - const AssetCriticalityIssueCallout: React.FC = () => { - const errorMessage = assetCriticalityPrivilegesError?.body.message ?? ( - - ); + const isMutationLoading = + enableStoreMutation.isLoading || + stopEntityEngineMutation.isLoading || + deleteEntityEngineMutation.isLoading; - return ( - + const callouts = (entityStoreStatus.data?.engines || []) + .filter((engine) => engine.status === 'error') + .map((engine) => { + const err = engine.error as { + message: string; + }; + + return ( } - color="primary" - iconType="iInCircle" + color="danger" + iconType="alert" > - {errorMessage} +

{err?.message}

-
- ); - }; - - const ClearEntityDataPanel: React.FC = () => { - return ( - <> - - -

- -

- - -
- - { - showClearModal(); - }} - > - - -
- {isClearModalVisible && ( - - } - onCancel={closeClearModal} - onConfirm={() => { - deleteEntityEngineMutation.mutate(); - }} - cancelButtonText={ - - } - confirmButtonText={ - - } - buttonColor="danger" - defaultFocusedButton="confirm" - > - - - )} - - ); - }; - - const FileUploadSection: React.FC = () => { - if ( - !hasEntityAnalyticsCapability || - assetCriticalityPrivilegesError?.body.status_code === 403 - ) { - return ; - } - if (!hasAssetCriticalityWritePermissions) { - return ; - } - return ( - - -

- -

-
- - - - - - -
- ); - }; - - const canDeleteEntityEngine = !['not_installed', 'loading', 'installing'].includes( - entityStoreStatus.status - ); - - const isMutationLoading = - initEntityEngineMutation.isLoading || - stopEntityEngineMutation.isLoading || - deleteEntityEngineMutation.isLoading; - - const callouts = entityStoreStatus.errors.map((error) => ( - - } - color="danger" - iconType="alert" - > -

{error.message}

-
- )); + ); + }); return ( <> @@ -291,15 +144,10 @@ export const EntityStoreManagementPage = () => { !isEntityStoreFeatureFlagDisabled && privileges?.has_all_required ? [ , ] : [] @@ -324,10 +172,14 @@ export const EntityStoreManagementPage = () => { - + - {initEntityEngineMutation.isError && ( + {enableStoreMutation.isError && ( { color="danger" iconType="alert" > -

- {(initEntityEngineMutation.error as { body: { message: string } }).body.message} -

+

{(enableStoreMutation.error as { body: { message: string } }).body.message}

)} {deleteEntityEngineMutation.isError && ( @@ -363,7 +213,16 @@ export const EntityStoreManagementPage = () => { {!isEntityStoreFeatureFlagDisabled && privileges?.has_all_required && - canDeleteEntityEngine && } + canDeleteEntityEngine(entityStoreStatus.data?.status) && ( + + )}
@@ -452,15 +311,15 @@ const EntityStoreFeatureFlagNotAvailableCallout: React.FC = () => { ); }; -const EntityStoreHealth: React.FC<{ currentEntityStoreStatus: string }> = ({ +const EntityStoreHealth: React.FC<{ currentEntityStoreStatus?: StoreStatus }> = ({ currentEntityStoreStatus, }) => { return ( - {entityStoreEnabledStatuses.includes(currentEntityStoreStatus) ? 'On' : 'Off'} + {isEntityStoreEnabled(currentEntityStoreStatus) ? 'On' : 'Off'} ); }; @@ -468,7 +327,7 @@ const EntityStoreHealth: React.FC<{ currentEntityStoreStatus: string }> = ({ const EnablementButton: React.FC<{ isLoading: boolean; isDisabled: boolean; - status: string; + status?: StoreStatus; onSwitch: () => void; }> = ({ isLoading, isDisabled, status, onSwitch }) => { return ( @@ -484,7 +343,7 @@ const EnablementButton: React.FC<{ label="" onChange={onSwitch} data-test-subj="entity-store-switch" - checked={entityStoreEnabledStatuses.includes(status)} + checked={isEntityStoreEnabled(status)} disabled={isDisabled} /> @@ -515,3 +374,150 @@ const InsufficientAssetCriticalityPrivilegesCallout: React.FC = () => { ); }; + +const AssetCriticalityIssueCallout: React.FC = ({ + errorMessage, +}: { + errorMessage?: string | ReactNode; +}) => { + const msg = errorMessage ?? ( + + ); + + return ( + + + } + color="primary" + iconType="iInCircle" + > + {msg} + + + ); +}; + +const ClearEntityDataPanel: React.FC<{ + deleteEntityEngineMutation: ReturnType; + isClearModalVisible: boolean; + closeClearModal: () => void; + showClearModal: () => void; +}> = ({ deleteEntityEngineMutation, isClearModalVisible, closeClearModal, showClearModal }) => { + return ( + <> + + +

+ +

+ + +
+ + { + showClearModal(); + }} + > + + +
+ {isClearModalVisible && ( + + } + onCancel={closeClearModal} + onConfirm={() => { + deleteEntityEngineMutation.mutate(); + }} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="danger" + defaultFocusedButton="confirm" + > + + + )} + + ); +}; + +const FileUploadSection: React.FC<{ + assetCriticalityPrivilegesError: SecurityAppError | null; + hasEntityAnalyticsCapability: boolean; + hasAssetCriticalityWritePermissions?: boolean; +}> = ({ + assetCriticalityPrivilegesError, + hasEntityAnalyticsCapability, + hasAssetCriticalityWritePermissions, +}) => { + if (!hasEntityAnalyticsCapability || assetCriticalityPrivilegesError?.body.status_code === 403) { + return ; + } + if (!hasAssetCriticalityWritePermissions) { + return ; + } + return ( + + +

+ +

+
+ + + + + + +
+ ); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboard.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboard.cy.ts new file mode 100644 index 0000000000000..272207aaf7f8f --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboard.cy.ts @@ -0,0 +1,67 @@ +/* + * 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 { login } from '../../tasks/login'; +import { visit } from '../../tasks/navigation'; +import { ENTITY_ANALYTICS_DASHBOARD_URL } from '../../urls/navigation'; + +import { deleteRiskEngineConfiguration } from '../../tasks/api_calls/risk_engine'; + +import { + PAGE_TITLE, + ENTITY_STORE_ENABLEMENT_PANEL, + ENABLEMENT_MODAL_RISK_SCORE_SWITCH, + ENABLEMENT_MODAL_ENTITY_STORE_SWITCH, +} from '../../screens/entity_analytics/dashboard'; +import { + openEntityStoreEnablementModal, + confirmEntityStoreEnablement, + waitForEntitiesListToAppear, +} from '../../tasks/entity_analytics'; + +describe( + 'Entity analytics dashboard page', + { + tags: ['@ess'], + }, + () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: 'all_users' }); + }); + + beforeEach(() => { + login(); + deleteRiskEngineConfiguration(); + visit(ENTITY_ANALYTICS_DASHBOARD_URL); + }); + + after(() => { + cy.task('esArchiverUnload', { archiveName: 'all_users' }); + }); + + it('renders page as expected', () => { + cy.get(PAGE_TITLE).should('have.text', 'Entity Analytics'); + }); + + describe('Entity Store enablement', () => { + it('renders enablement panel', () => { + cy.get(ENTITY_STORE_ENABLEMENT_PANEL).contains('Enable entity store and risk score'); + }); + + it('enables risk score followed by the store', () => { + openEntityStoreEnablementModal(); + + cy.get(ENABLEMENT_MODAL_RISK_SCORE_SWITCH).should('be.visible'); + cy.get(ENABLEMENT_MODAL_ENTITY_STORE_SWITCH).should('be.visible'); + + confirmEntityStoreEnablement(); + + waitForEntitiesListToAppear(); + }); + }); + } +); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/entity_analytics/dashboard.ts b/x-pack/test/security_solution_cypress/cypress/screens/entity_analytics/dashboard.ts new file mode 100644 index 0000000000000..78b619979e969 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/screens/entity_analytics/dashboard.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PAGE_TITLE = '[data-test-subj="entityAnalyticsPage"]'; + +export const ENTITY_STORE_ENABLEMENT_PANEL = '[data-test-subj="entityStoreEnablementPanel"]'; +export const ENTITY_STORE_ENABLEMENT_BUTTON = '[data-test-subj="entityStoreEnablementButton"]'; + +export const ENTITY_STORE_ENABLEMENT_MODAL = '[data-test-subj="entityStoreEnablementModal"]'; + +export const ENABLEMENT_MODAL_RISK_SCORE_SWITCH = '[data-test-subj="enablementRiskScoreSwitch"]'; +export const ENABLEMENT_MODAL_ENTITY_STORE_SWITCH = + '[data-test-subj="enablementEntityStoreSwitch"]'; + +export const ENABLEMENT_MODAL_CONFIRM_BUTTON = + '[data-test-subj="entityStoreEnablementModalButton"]'; + +export const ENABLEMENT_RISK_ENGINE_INITIALIZING_PANEL = + '[data-test-subj="riskEngineInitializingPanel"]'; + +export const ENABLEMENT_ENTITY_STORE_INITIALIZING_PANEL = + '[data-test-subj="entityStoreInitializingPanel"]'; + +export const ENTITIES_LIST_PANEL = '[data-test-subj="entitiesListPanel"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/entity_analytics.ts b/x-pack/test/security_solution_cypress/cypress/tasks/entity_analytics.ts index 2265f228c2ce7..91a5c36a51928 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/entity_analytics.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/entity_analytics.ts @@ -27,6 +27,12 @@ import { import { visitWithTimeRange } from './navigation'; import { GET_DATE_PICKER_APPLY_BUTTON, GLOBAL_FILTERS_CONTAINER } from '../screens/date_picker'; import { REFRESH_BUTTON } from '../screens/security_header'; +import { + ENABLEMENT_MODAL_CONFIRM_BUTTON, + ENTITIES_LIST_PANEL, + ENTITY_STORE_ENABLEMENT_BUTTON, + ENTITY_STORE_ENABLEMENT_MODAL, +} from '../screens/entity_analytics/dashboard'; export const updateDashboardTimeRange = () => { // eslint-disable-next-line cypress/no-force @@ -113,3 +119,17 @@ export const upgradeRiskEngine = () => { updateRiskEngineConfirm(); cy.get(RISK_SCORE_STATUS).should('have.text', 'On'); }; + +export const openEntityStoreEnablementModal = () => { + cy.get(ENTITY_STORE_ENABLEMENT_BUTTON).click(); + cy.get(ENTITY_STORE_ENABLEMENT_MODAL).contains('Entity Analytics Enablement'); +}; + +export const confirmEntityStoreEnablement = () => { + cy.get(ENABLEMENT_MODAL_CONFIRM_BUTTON).click(); +}; + +export const waitForEntitiesListToAppear = () => { + cy.get(ENTITIES_LIST_PANEL, { timeout: 30000 }).scrollIntoView(); + cy.get(ENTITIES_LIST_PANEL).contains('Entities'); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/urls/navigation.ts b/x-pack/test/security_solution_cypress/cypress/urls/navigation.ts index b5855192d18e1..6f6f4dc1a108a 100644 --- a/x-pack/test/security_solution_cypress/cypress/urls/navigation.ts +++ b/x-pack/test/security_solution_cypress/cypress/urls/navigation.ts @@ -79,3 +79,6 @@ export const exceptionsListDetailsUrl = (listId: string) => export const DISCOVER_URL = '/app/discover'; export const OSQUERY_URL = '/app/osquery'; export const FLEET_URL = '/app/fleet'; + +// Entity Analytics +export const ENTITY_ANALYTICS_DASHBOARD_URL = '/app/security/entity_analytics';