Skip to content

Commit

Permalink
[SecuritySolution] Add "Install" and "Reinstall" button on Entity Sto…
Browse files Browse the repository at this point in the history
…re status page (elastic#208149)

## Summary

Add "Install" and "Reinstall" buttons on Entity Store status page 
* It also adds an extra loading state for the 'enable' switch in the
header

![Screenshot 2025-01-24 at 11 14
33](https://github.com/user-attachments/assets/b12ceae5-77c1-41bd-ad4c-58bdad3d6891)



### How to test it?
**1)**
* Start a Kibana repository with entity data
* Install the entity store
* Delete one engine using Dev Tools `DELETE
kbn:/api/entity_store/engines/user`
* Go to the manage entity store status tab 
* Verify that it displays an install button for the uninstalled engine
* Install the engine

**2)**
* Start a Kibana repository with entity data
* Install the entity store
* Go to the manage entity store status tab 
* Delete one component of an installed engine (transform)
* Go to the manage entity store status tab 
* Verify it displays a reinstall button for the engine you uninstalled
the component
* Reinstall the engine
* Everything should look ok
* Extra step: Verify if there was any data lost after reinstalling the
engine



### Checklist

Reviewers should verify this PR satisfies this list as well.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
  • Loading branch information
machadoum authored Jan 27, 2025
1 parent 009b377 commit 15c622b
Show file tree
Hide file tree
Showing 9 changed files with 328 additions and 57 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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 { render } from '@testing-library/react';
import { EngineStatusHeader } from './engine_status_header';
import { capitalize } from 'lodash/fp';
import { EntityType } from '../../../../../../../common/entity_analytics/types';
import { TestProviders } from '../../../../../../common/mock';

describe('EngineStatusHeader', () => {
it('renders the title with the capitalized entity type', () => {
const { getByText } = render(<EngineStatusHeader entityType={EntityType.host} />, {
wrapper: TestProviders,
});
expect(getByText(`${capitalize(EntityType.host)} Store`)).toBeInTheDocument();
});

it('renders the action button if provided', () => {
const actionButton = <button type="button">{'Click me'}</button>;
const { getByText } = render(
<EngineStatusHeader entityType={EntityType.host} actionButton={actionButton} />,
{
wrapper: TestProviders,
}
);
expect(getByText('Click me')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { EuiFlexItem, EuiTitle, EuiFlexGroup } from '@elastic/eui';
import { capitalize } from 'lodash/fp';
import { FormattedMessage } from '@kbn/i18n-react';
import type { EntityType } from '../../../../../../../common/entity_analytics/types';

export const EngineStatusHeader = ({
entityType,
actionButton,
}: {
entityType: EntityType;
actionButton?: React.ReactNode;
}) => (
<EuiTitle size="s">
<h4>
<EuiFlexGroup direction="row" gutterSize="m" alignItems="baseline" responsive={false}>
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enginesStatus.title"
defaultMessage="{type} Store"
values={{
type: capitalize(entityType),
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} direction="row">
{actionButton}
</EuiFlexItem>
</EuiFlexGroup>
</h4>
</EuiTitle>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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 { render, screen, fireEvent } from '@testing-library/react';
import { EngineStatusHeaderAction } from './engine_status_header_action';
import { useEnableEntityStoreMutation } from '../../../hooks/use_entity_store';
import { isEngineLoading } from '../helpers';
import type { GetEntityStoreStatusResponse } from '../../../../../../../common/api/entity_analytics/entity_store/status.gen';
import { EntityType } from '../../../../../../../common/entity_analytics/types';
import { TestProviders } from '../../../../../../common/mock';
import type { EngineComponentStatus } from '../../../../../../../common/api/entity_analytics';

jest.mock('../../../hooks/use_entity_store');
jest.mock('../helpers');

const mockUseEnableEntityStoreMutation = useEnableEntityStoreMutation as jest.Mock;
const mockIsEngineLoading = isEngineLoading as jest.Mock;

const defaultComponent: EngineComponentStatus = {
id: 'component1',
resource: 'entity_engine',
installed: true,
};

const defaultEngineResponse: GetEntityStoreStatusResponse['engines'][0] = {
type: EntityType.user,
indexPattern: '',
status: 'started',
fieldHistoryLength: 0,
components: [defaultComponent],
lookbackPeriod: '',
};

describe('EngineStatusHeaderAction', () => {
beforeEach(() => {
mockUseEnableEntityStoreMutation.mockReturnValue({
mutate: jest.fn(),
isLoading: false,
});
mockIsEngineLoading.mockReturnValue(false);
});

it('renders loading spinner when loading', () => {
mockUseEnableEntityStoreMutation.mockReturnValue({
mutate: jest.fn(),
isLoading: true,
});

render(<EngineStatusHeaderAction engine={undefined} type={EntityType.user} />, {
wrapper: TestProviders,
});
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});

it('renders install button when engine is undefined', () => {
render(<EngineStatusHeaderAction engine={undefined} type={EntityType.user} />, {
wrapper: TestProviders,
});
expect(screen.getByText('Install')).toBeInTheDocument();
});

it('calls installEntityStore when install button is clicked', () => {
const mutate = jest.fn();
mockUseEnableEntityStoreMutation.mockReturnValue({
mutate,
isLoading: false,
});

render(<EngineStatusHeaderAction engine={undefined} type={EntityType.user} />, {
wrapper: TestProviders,
});
fireEvent.click(screen.getByText('Install'));
expect(mutate).toHaveBeenCalledWith({ entityTypes: [EntityType.user] });
});

it('calls installEntityStore when reinstall button is clicked', () => {
const engine: GetEntityStoreStatusResponse['engines'][0] = {
...defaultEngineResponse,
components: [{ ...defaultComponent, installed: false }],
};
const mutate = jest.fn();
mockUseEnableEntityStoreMutation.mockReturnValue({
mutate,
isLoading: false,
});

render(<EngineStatusHeaderAction engine={engine} type={EntityType.user} />, {
wrapper: TestProviders,
});
fireEvent.click(screen.getByText('Reinstall'));
expect(mutate).toHaveBeenCalledWith({ entityTypes: [EntityType.user] });
});

it('renders reinstall button and tooltip when a component is not installed', () => {
const engine: GetEntityStoreStatusResponse['engines'][0] = {
...defaultEngineResponse,
components: [{ ...defaultComponent, installed: false }],
};

render(<EngineStatusHeaderAction engine={engine} type={EntityType.user} />, {
wrapper: TestProviders,
});
expect(screen.getByText('Reinstall')).toBeInTheDocument();
});

it('renders not action when engine is defined and no error', () => {
render(<EngineStatusHeaderAction engine={defaultEngineResponse} type={EntityType.user} />, {
wrapper: TestProviders,
});
expect(screen.queryByText('Install')).not.toBeInTheDocument();
expect(screen.queryByText('Reinstall')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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 { EuiLoadingSpinner, EuiButtonEmpty, EuiIconTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useEnableEntityStoreMutation } from '../../../hooks/use_entity_store';
import type { GetEntityStoreStatusResponse } from '../../../../../../../common/api/entity_analytics/entity_store/status.gen';
import type { EntityType } from '../../../../../../../common/entity_analytics/types';
import { isEngineLoading } from '../helpers';

export function EngineStatusHeaderAction({
engine,
type,
}: {
engine: GetEntityStoreStatusResponse['engines'][0] | undefined;
type: EntityType;
}) {
const enableEntityStore = useEnableEntityStoreMutation();
const installEntityStore = () => {
enableEntityStore.mutate({ entityTypes: [type] });
};
const hasUninstalledComponent = engine?.components?.some(({ installed }) => !installed);

if (enableEntityStore.isLoading || isEngineLoading(engine?.status)) {
return <EuiLoadingSpinner size="s" />;
}

if (!engine) {
return (
<EuiButtonEmpty onClick={installEntityStore}>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enginesStatus.installButton"
defaultMessage="Install"
/>
</EuiButtonEmpty>
);
}

if (hasUninstalledComponent) {
return (
<div>
<EuiButtonEmpty onClick={installEntityStore} iconType="refresh" color="warning">
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enginesStatus.reinstallButton"
defaultMessage="Reinstall"
/>
</EuiButtonEmpty>

<EuiIconTip
content={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.entityStore.enginesStatus.reinstallToolTip"
defaultMessage="The components associated with this entity type are experiencing issues. Reinstall them to restore functionality"
/>
}
color="warning"
position="right"
type="iInCircle"
/>
</div>
);
}

return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { EngineStatus } from '../../../../../../common/api/entity_analytics/entity_store/common.gen';

export const isEngineLoading = (status: EngineStatus | undefined) =>
status === 'updating' || status === 'installing';
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,28 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { EngineStatus } from '.';

import { TestProviders } from '@kbn/timelines-plugin/public/mock';
import { mockGlobalState } from '../../../../../common/mock';
import { EntityType } from '../../../../../../common/entity_analytics/types';

const mockUseEntityStore = jest.fn();
jest.mock('../../hooks/use_entity_store', () => ({
useEntityStoreStatus: () => mockUseEntityStore(),
useEnableEntityStoreMutation: () => ({
mutate: jest.fn(),
isLoading: false,
}),
}));

const mockDownloadBlob = jest.fn();
jest.mock('../../../../../common/utils/download_blob', () => ({
downloadBlob: () => mockDownloadBlob(),
}));

const mockedExperimentalFeatures = mockGlobalState.app.enableExperimental;
jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
useEnableExperimental: () => mockedExperimentalFeatures,
}));

describe('EngineStatus', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -66,7 +77,7 @@ describe('EngineStatus', () => {
const mockData = {
engines: [
{
type: 'test',
type: EntityType.user,
components: [{ id: 'entity_engine_id', installed: true, resource: 'entity_engine' }],
},
],
Expand All @@ -75,15 +86,15 @@ describe('EngineStatus', () => {

render(<EngineStatus />, { wrapper: TestProviders });

expect(screen.getByText('Test Store')).toBeInTheDocument();
expect(screen.getByText('User Store')).toBeInTheDocument();
expect(screen.getByText('Download status')).toBeInTheDocument();
});

it('calls downloadJson when download button is clicked', () => {
const mockData = {
engines: [
{
type: 'test',
type: EntityType.user,
components: [{ id: 'entity_engine_id', installed: true, resource: 'entity_engine' }],
},
],
Expand Down
Loading

0 comments on commit 15c622b

Please sign in to comment.