Skip to content

Commit

Permalink
[Entity Analytics][Entity Store] Refactor enablement UI (elastic#199762)
Browse files Browse the repository at this point in the history
## Summary

This PR reworks the client side enablement flow for the Entity Store.
It's the final piece for the work tracked in
elastic/security-team#10846,
elastic/security-team#10847 and
elastic/security-team#10947


https://github.com/user-attachments/assets/bb919c3c-b8dc-4e6b-a14b-4d413f8da13f

## How to test

Optionally 

On a fresh kibana and es cluster instance:
1. Load up some entity analytics data via the
https://github.com/elastic/security-documents-generator
    * Running `yarn start entity-resolution-demo --mini` is enough 
1. Navigate to `Security > Dashboards > Entity Analytics`
3. Click the `Enable` entity store button
4. Both Risk Score and Entity Store toggles should be checked.
* This state represents the engines we will install, _NOT_ the current
state
5. Click `Enable`
6. The modal should close and Risk Scoring should initialize first
7. As soon as risk score initialization finished, the entity store
initialization should start
* Risk score related panels should show up while the store is
initializing
8. Finally, the Entities List panel should appear   


##
 
- [x] added cypress tests to verify the correct enablement flow
  • Loading branch information
tiansivive authored Nov 28, 2024
1 parent c2c6f56 commit 722900e
Show file tree
Hide file tree
Showing 14 changed files with 703 additions and 681 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,15 +25,32 @@ 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<InitEntityStoreResponse>('/api/entity_store/enable', {
method: 'POST',
version: API_VERSIONS.public.v1,
body: JSON.stringify(options),
});
};

const getEntityStoreStatus = async () => {
return http.fetch<GetEntityStoreStatusResponse>('/api/entity_store/status', {
method: 'GET',
version: API_VERSIONS.public.v1,
});
};

const initEntityEngine = async (entityType: EntityType) => {
return http.fetch<InitEntityEngineResponse>(`/api/entity_store/engines/${entityType}/init`, {
method: 'POST',
version: API_VERSIONS.public.v1,
body: JSON.stringify({}),
});
};

const stopEntityStore = async (entityType: EntityType) => {
const stopEntityEngine = async (entityType: EntityType) => {
return http.fetch<StopEntityEngineResponse>(`/api/entity_store/engines/${entityType}/stop`, {
method: 'POST',
version: API_VERSIONS.public.v1,
Expand Down Expand Up @@ -59,8 +81,10 @@ export const useEntityStoreRoutes = () => {
};

return {
initEntityStore,
stopEntityStore,
enableEntityStore,
getEntityStoreStatus,
initEntityEngine,
stopEntityEngine,
getEntityEngine,
deleteEntityEngine,
listEntityEngines,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RiskEngineStatus>;
entityStore: UseQueryResult<GetEntityStoreStatusResponse>;
};
}

export const EnablementPanel: React.FC<EnableEntityStorePanelProps> = ({ 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 (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enablement.mutation.errorTitle"
defaultMessage={'There was a problem initializing the entity store'}
/>
}
color="danger"
iconType="error"
>
<p>{storeEnablement.error.body.message}</p>
</EuiCallOut>
</>
);
}

if (riskEngineInitializing) {
return (
<EuiPanel hasBorder data-test-subj="riskEngineInitializingPanel">
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoElastic" size="xl" />}
title={<h2>{ENABLEMENT_INITIALIZING_RISK_ENGINE}</h2>}
/>
</EuiPanel>
);
}

if (entityStoreStatus === 'installing' || storeEnablement.isLoading) {
return (
<EuiPanel hasBorder data-test-subj="entityStoreInitializingPanel">
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoElastic" size="xl" />}
title={<h2>{ENABLEMENT_INITIALIZING_ENTITY_STORE}</h2>}
body={
<p>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enablement.initializing.description"
defaultMessage="This can take up to 5 minutes."
/>
</p>
}
/>
</EuiPanel>
);
}

if (
riskEngineStatus !== RiskEngineStatusEnum.NOT_INSTALLED &&
(entityStoreStatus === 'running' || entityStoreStatus === 'stopped')
) {
return null;
}

const [title, body] = getEnablementTexts(entityStoreStatus, riskEngineStatus);
return (
<>
<EuiEmptyPrompt
css={{ minWidth: '100%' }}
hasBorder
layout="horizontal"
title={<h2>{title}</h2>}
body={<p>{body}</p>}
actions={
<EuiToolTip content={title}>
<EuiButton
color="primary"
fill
onClick={() => setModalState({ visible: true })}
data-test-subj={`entityStoreEnablementButton`}
>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enablement.enableButton"
defaultMessage="Enable"
/>
</EuiButton>
</EuiToolTip>
}
icon={<EuiImage size="l" hasShadow src={dashboardEnableImg} alt={title} />}
data-test-subj="entityStoreEnablementPanel"
/>

<EntityStoreEnablementModal
visible={modal.visible}
toggle={(visible) => 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];
};
Original file line number Diff line number Diff line change
@@ -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 (
<EuiCallOut
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enablement.errors.title"
defaultMessage={'An error occurred during entity store resource initialization'}
/>
}
color="danger"
iconType="error"
>
<p>{err?.message}</p>
</EuiCallOut>
);
});

if (storeStatusQuery.status === 'loading') {
return (
<EuiPanel hasBorder>
<EuiEmptyPrompt icon={<EuiLoadingSpinner size="xl" />} />
</EuiPanel>
);
}

return (
<EuiFlexGroup direction="column" data-test-subj="entityStorePanelsGroup">
{storeStatusQuery.status === 'error' ? (
callouts
) : (
<EnablementPanel
state={{
riskEngine: riskEngineStatus,
entityStore: storeStatusQuery,
}}
/>
)}

{riskEngineStatus.data?.risk_engine_status !== RiskEngineStatusEnum.NOT_INSTALLED && (
<>
<EuiFlexItem>
<EntityAnalyticsRiskScores riskEntity={RiskScoreEntity.user} />
</EuiFlexItem>
<EuiFlexItem>
<EntityAnalyticsRiskScores riskEntity={RiskScoreEntity.host} />
</EuiFlexItem>
</>
)}
{storeStatusQuery.data?.status !== 'not_installed' &&
storeStatusQuery.data?.status !== 'installing' && (
<EuiFlexItem data-test-subj="entitiesListPanel">
<EntitiesList />
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

export const EntityStoreDashboardPanels = React.memo(EntityStoreDashboardPanelsComponent);
EntityStoreDashboardPanels.displayName = 'EntityStoreDashboardPanels';
Loading

0 comments on commit 722900e

Please sign in to comment.