mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
Asset Inventory Onboarding and Context Integration (#212315)
### Summary It closes #210713 This PR introduces the **onboarding flow for Asset Inventory**, ensuring users are guided through an enablement process when accessing the Asset Inventory page for the first time. #### Changes: **Asset Inventory API Forwarding** - The Asset Inventory API now proxies enablement requests to the **Entity Store API** (`/api/entity_store/engines/enable`). - This ensures that any future **enhancements for Asset Inventory enablement** are already handled on the server side. **Asset Inventory Context** - Introduced the `AssetInventoryContext` to centralize **Asset Inventory status management** based on the `/api/entity_store/engines/status` data (`disabled`, `initializing`, `ready`, etc.). - Allows any component to **consume the onboarding state** and react accordingly. **"Get Started" Onboarding Experience** - Implemented a **new onboarding screen** that appears when Asset Inventory is disabled. - Includes: - Informative **title and description** about the feature. - A **call-to-action button** to enable Asset Inventory. - **Loading states and error handling** for the API call. **API Integration and Hooks** - Created `useEnableAssetInventory` to abstract and handle enablement logic via **React Query**. - Created `useAssetInventoryRoutes` to abstract API calls for fetching and enabling Asset Inventory. **HoverForExplanation Component** - Introduced `HoverForExplanation`, a **tooltip-based helper component** that enhances the onboarding description. - Provides **inline explanations** for key terms like **identity providers, cloud services, MDMs, and databases**, ensuring users understand **data sources** in Asset Inventory. **Testing & Error Handling** - Added **unit tests** for the onboarding component and hooks. - Implemented error handling for failed API requests (e.g., permission errors, server failures). #### Screenshots  https://github.com/user-attachments/assets/1280404e-9cb3-4288-91a7-640f8f1b458a #### How to test it locally - Ensure the `assetInventoryUXEnabled` feature flag is enabled on kibana.yml file: ``` xpack.securitySolution.enableExperimental: ['assetInventoryUXEnabled'] ``` - Ensure the Entity Store is Off and data is removed (initial state), so the onboarding is visible (If the Entity Store is installed by other means the onboarding will direct users to the empty state component or to the all assets page) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
f8ba372106
commit
bdc4790272
25 changed files with 883 additions and 24 deletions
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 ASSET_INVENTORY_ENABLE_API_PATH = '/api/asset_inventory/enable';
|
||||
export const ASSET_INVENTORY_STATUS_API_PATH = '/api/asset_inventory/status';
|
||||
export const ASSET_INVENTORY_DELETE_API_PATH = '/api/asset_inventory/delete';
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import type { ServerApiError } from '../../../public/common/types';
|
||||
import type { EntityAnalyticsPrivileges } from '../entity_analytics';
|
||||
import type { InitEntityStoreResponse } from '../entity_analytics/entity_store/enable.gen';
|
||||
|
||||
export type AssetInventoryStatus =
|
||||
| 'disabled'
|
||||
| 'initializing'
|
||||
| 'empty'
|
||||
| 'permission_denied'
|
||||
| 'ready';
|
||||
|
||||
export interface AssetInventoryStatusResponse {
|
||||
status: AssetInventoryStatus;
|
||||
privileges?: EntityAnalyticsPrivileges['privileges'];
|
||||
}
|
||||
|
||||
export type AssetInventoryEnableResponse = InitEntityStoreResponse;
|
||||
|
||||
export interface AssetInventoryServerApiError {
|
||||
body: ServerApiError;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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 { EuiFlexGroup, EuiFlexItem, EuiLoadingLogo, EuiSpacer } from '@elastic/eui';
|
||||
import { InventoryTitle } from './inventory_title';
|
||||
import { CenteredWrapper } from './onboarding/centered_wrapper';
|
||||
|
||||
/**
|
||||
* A loading state for the asset inventory page.
|
||||
*/
|
||||
export const AssetInventoryLoading = () => (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<InventoryTitle />
|
||||
<EuiSpacer size="l" />
|
||||
<CenteredWrapper>
|
||||
<EuiLoadingLogo logo="logoSecurity" size="xl" />
|
||||
</CenteredWrapper>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
import React from 'react';
|
||||
|
||||
// width of the illustration used in the empty states
|
||||
const DEFAULT_ILLUSTRATION_WIDTH = 360;
|
||||
|
||||
/**
|
||||
* A container component that maintains a fixed width for SVG elements,
|
||||
* this prevents the EmptyState component from flickering while the SVGs are loading.
|
||||
*/
|
||||
export const EmptyStateIllustrationContainer: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => (
|
||||
<div
|
||||
css={css`
|
||||
width: ${DEFAULT_ILLUSTRATION_WIDTH}px;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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 { EuiTitle } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
export const InventoryTitle = () => {
|
||||
return (
|
||||
<EuiTitle size="l" data-test-subj="inventory-title">
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.inventoryTitle"
|
||||
defaultMessage="Inventory"
|
||||
/>
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 type { FC, PropsWithChildren } from 'react';
|
||||
import { GetStarted } from './get_started';
|
||||
import { AssetInventoryLoading } from '../asset_inventory_loading';
|
||||
import { useAssetInventoryStatus } from '../../hooks/use_asset_inventory_status';
|
||||
|
||||
/**
|
||||
* This component serves as a wrapper to render appropriate onboarding screens
|
||||
* based on the current onboarding status. If no specific onboarding status
|
||||
* matches, it will render the child components.
|
||||
*/
|
||||
export const AssetInventoryOnboarding: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { data, isLoading } = useAssetInventoryStatus();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <AssetInventoryLoading />;
|
||||
}
|
||||
|
||||
const { status, privileges } = data;
|
||||
|
||||
// Render different screens based on the onboarding status.
|
||||
switch (status) {
|
||||
case 'disabled': // The user has not yet started the onboarding process.
|
||||
return <GetStarted />;
|
||||
case 'initializing': // Todo: The onboarding process is currently initializing.
|
||||
return <div>{'Initializing...'}</div>;
|
||||
case 'empty': // Todo: Onboarding cannot proceed because no relevant data was found.
|
||||
return <div>{'No data found.'}</div>;
|
||||
case 'permission_denied': // Todo: User lacks the necessary permissions to proceed.
|
||||
return (
|
||||
<div>
|
||||
{'Permission denied.'}
|
||||
<pre>{JSON.stringify(privileges)}</pre>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
// If no onboarding status matches, render the child components.
|
||||
return children;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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 { EuiFlexGroup, EuiFlexItem, type CommonProps } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* A wrapper that centers its children both horizontally and vertically.
|
||||
*/
|
||||
export const CenteredWrapper = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & CommonProps) => (
|
||||
<EuiFlexGroup
|
||||
css={css`
|
||||
// 250px is roughly the Kibana chrome with a page title and tabs
|
||||
min-height: calc(100vh - 250px);
|
||||
`}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
direction="column"
|
||||
{...rest}
|
||||
>
|
||||
<EuiFlexItem>{children}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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, waitFor } from '@testing-library/react';
|
||||
import { GetStarted } from './get_started';
|
||||
import { useEnableAssetInventory } from './hooks/use_enable_asset_inventory';
|
||||
import { TestProvider } from '../../test/test_provider';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
|
||||
jest.mock('./hooks/use_enable_asset_inventory', () => ({
|
||||
useEnableAssetInventory: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetStarted = {
|
||||
isEnabling: false,
|
||||
error: null,
|
||||
reset: jest.fn(),
|
||||
enableAssetInventory: jest.fn(),
|
||||
};
|
||||
|
||||
const renderWithProvider = (children: React.ReactNode) => {
|
||||
return render(<TestProvider>{children}</TestProvider>);
|
||||
};
|
||||
|
||||
describe('GetStarted Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
(useEnableAssetInventory as jest.Mock).mockReturnValue(mockGetStarted);
|
||||
});
|
||||
|
||||
it('renders the component', () => {
|
||||
renderWithProvider(<GetStarted />);
|
||||
|
||||
expect(screen.getByText(/get started with asset inventory/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /enable asset inventory/i })).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/read documentation/i).closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://ela.st/asset-inventory'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls enableAssetInventory when enable asset inventory button is clicked', async () => {
|
||||
renderWithProvider(<GetStarted />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /enable asset inventory/i }));
|
||||
|
||||
expect(mockGetStarted.enableAssetInventory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a loading spinner when enabling', () => {
|
||||
(useEnableAssetInventory as jest.Mock).mockReturnValue({
|
||||
...mockGetStarted,
|
||||
isEnabling: true,
|
||||
});
|
||||
|
||||
renderWithProvider(<GetStarted />);
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /enabling asset inventory/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays an error message when there is an error', () => {
|
||||
const errorMessage = 'Task Manager is not available';
|
||||
(useEnableAssetInventory as jest.Mock).mockReturnValue({
|
||||
...mockGetStarted,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
renderWithProvider(<GetStarted />);
|
||||
|
||||
expect(screen.getByText(/sorry, there was an error/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls reset when error message is dismissed', async () => {
|
||||
(useEnableAssetInventory as jest.Mock).mockReturnValue({
|
||||
...mockGetStarted,
|
||||
error: 'Task Manager is not available',
|
||||
});
|
||||
|
||||
renderWithProvider(<GetStarted />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
|
||||
await waitFor(() => expect(mockGetStarted.reset).toHaveBeenCalled());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,193 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiImage,
|
||||
EuiEmptyPrompt,
|
||||
EuiButton,
|
||||
EuiLink,
|
||||
EuiTitle,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiCallOut,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import illustration from '../../../common/images/information_light.png';
|
||||
import { InventoryTitle } from '../inventory_title';
|
||||
import { CenteredWrapper } from './centered_wrapper';
|
||||
import { HoverForExplanationTooltip } from './hover_for_explanation_tooltip';
|
||||
import { EmptyStateIllustrationContainer } from '../empty_state_illustration_container';
|
||||
import { useEnableAssetInventory } from './hooks/use_enable_asset_inventory';
|
||||
|
||||
const ASSET_INVENTORY_DOCS_URL = 'https://ela.st/asset-inventory';
|
||||
const TEST_SUBJ = 'assetInventory:onboarding:get-started';
|
||||
|
||||
export const GetStarted = () => {
|
||||
const { isEnabling, error, reset, enableAssetInventory } = useEnableAssetInventory();
|
||||
|
||||
return (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<InventoryTitle />
|
||||
<CenteredWrapper>
|
||||
<EuiEmptyPrompt
|
||||
data-test-subj={TEST_SUBJ}
|
||||
icon={
|
||||
<EmptyStateIllustrationContainer>
|
||||
<EuiImage
|
||||
url={illustration}
|
||||
size="fullWidth"
|
||||
alt={i18n.translate(
|
||||
'xpack.securitySolution.assetInventory.emptyState.illustrationAlt',
|
||||
{
|
||||
defaultMessage: 'No results',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EmptyStateIllustrationContainer>
|
||||
}
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.onboarding.getStarted.title"
|
||||
defaultMessage="Get Started with Asset Inventory"
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
layout="horizontal"
|
||||
color="plain"
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.onboarding.getStarted.description"
|
||||
defaultMessage="Asset Inventory gives you a unified view of all assets detected by Elastic Security, including those observed in logs, events, or discovered through integrations with sources like {identity_providers}, {cloud_services}, {mdms}, and configuration management {databases}."
|
||||
values={{
|
||||
identity_providers: (
|
||||
<HoverForExplanationTooltip
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.onboarding.getStarted.description.identityProviders.helperText"
|
||||
defaultMessage="Identity providers are services that store and manage user identities."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.onboarding.getStarted.description.identityProviders"
|
||||
defaultMessage="identity providers"
|
||||
/>
|
||||
</HoverForExplanationTooltip>
|
||||
),
|
||||
cloud_services: (
|
||||
<HoverForExplanationTooltip
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.onboarding.getStarted.description.cloudServices.helperText"
|
||||
defaultMessage="Cloud services are services that provide cloud-based infrastructure, platforms, or software."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.onboarding.getStarted.description.cloudServices"
|
||||
defaultMessage="cloud services"
|
||||
/>
|
||||
</HoverForExplanationTooltip>
|
||||
),
|
||||
mdms: (
|
||||
<HoverForExplanationTooltip
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.onboarding.getStarted.description.mdms.helperText"
|
||||
defaultMessage="Mobile Device Managers (MDMs) are services that manage mobile devices."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<>{'MDMs'}</>
|
||||
</HoverForExplanationTooltip>
|
||||
),
|
||||
databases: (
|
||||
<HoverForExplanationTooltip
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.onboarding.getStarted.description.databases.helperText"
|
||||
defaultMessage="Databases are services that store and manage data."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.onboarding.getStarted.description.databases"
|
||||
defaultMessage="databases"
|
||||
/>
|
||||
</HoverForExplanationTooltip>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
{error && (
|
||||
<EuiCallOut
|
||||
onDismiss={reset}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.onboarding.getStarted.errorTitle"
|
||||
defaultMessage="Sorry, there was an error"
|
||||
/>
|
||||
}
|
||||
color="danger"
|
||||
iconType="error"
|
||||
>
|
||||
<p>{error}</p>
|
||||
</EuiCallOut>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={[
|
||||
<EuiButton
|
||||
color="primary"
|
||||
fill
|
||||
onClick={enableAssetInventory}
|
||||
iconType="plusInCircle"
|
||||
isLoading={isEnabling}
|
||||
>
|
||||
{isEnabling ? (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.emptyState.enableAssetInventory.loading"
|
||||
defaultMessage="Enabling Asset Inventory"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.emptyState.enableAssetInventory"
|
||||
defaultMessage="Enable Asset Inventory"
|
||||
/>
|
||||
)}
|
||||
</EuiButton>,
|
||||
]}
|
||||
footer={
|
||||
<>
|
||||
<EuiTitle size="xxs">
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.emptyState.needHelp"
|
||||
defaultMessage="Need help?"
|
||||
/>
|
||||
</strong>
|
||||
</EuiTitle>{' '}
|
||||
<EuiLink href={ASSET_INVENTORY_DOCS_URL} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.assetInventory.emptyState.readDocumentation"
|
||||
defaultMessage="Read documentation"
|
||||
/>
|
||||
</EuiLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</CenteredWrapper>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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 { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useEnableAssetInventory } from './use_enable_asset_inventory';
|
||||
import { createTestProviderWrapper } from '../../../test/test_provider';
|
||||
|
||||
const mockPostEnableAssetInventory = jest.fn();
|
||||
const mockRefetchStatus = jest.fn();
|
||||
|
||||
jest.mock('../../../hooks/use_asset_inventory_routes', () => ({
|
||||
useAssetInventoryRoutes: () => ({
|
||||
postEnableAssetInventory: mockPostEnableAssetInventory,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/use_asset_inventory_status', () => ({
|
||||
useAssetInventoryStatus: () => ({
|
||||
refetch: mockRefetchStatus,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderHookWithWrapper = () =>
|
||||
renderHook(() => useEnableAssetInventory(), {
|
||||
wrapper: createTestProviderWrapper(),
|
||||
});
|
||||
|
||||
describe('useEnableAssetInventory', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Success', () => {
|
||||
it('should call postEnableAssetInventory when enabling asset inventory', async () => {
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
result.current.enableAssetInventory();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPostEnableAssetInventory).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set isEnabling to true when enabling asset inventory', async () => {
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
result.current.enableAssetInventory();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isEnabling).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should refetch status when asset inventory is enabled', async () => {
|
||||
mockPostEnableAssetInventory.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
result.current.enableAssetInventory();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetchStatus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error', () => {
|
||||
it('should handle error message when enabling asset inventory', async () => {
|
||||
// suppress expected console error messages
|
||||
jest.spyOn(console, 'error').mockReturnValue();
|
||||
|
||||
mockPostEnableAssetInventory.mockRejectedValue({
|
||||
body: {
|
||||
message: 'Unexpected error occurred',
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
result.current.enableAssetInventory();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Unexpected error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include default error message when enabling asset inventory rejects with unexpected error message', async () => {
|
||||
mockPostEnableAssetInventory.mockRejectedValue({});
|
||||
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
result.current.enableAssetInventory();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Failed to enable Asset Inventory. Please try again.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 { useMutation } from '@tanstack/react-query';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type {
|
||||
AssetInventoryEnableResponse,
|
||||
AssetInventoryServerApiError,
|
||||
} from '../../../../../common/api/asset_inventory/types';
|
||||
import { useAssetInventoryRoutes } from '../../../hooks/use_asset_inventory_routes';
|
||||
import { useAssetInventoryStatus } from '../../../hooks/use_asset_inventory_status';
|
||||
|
||||
/**
|
||||
* Hook with related business logic for enabling Asset Inventory
|
||||
*/
|
||||
export const useEnableAssetInventory = () => {
|
||||
const { postEnableAssetInventory } = useAssetInventoryRoutes();
|
||||
const { refetch: refetchStatus } = useAssetInventoryStatus();
|
||||
|
||||
const mutation = useMutation<AssetInventoryEnableResponse, AssetInventoryServerApiError>(
|
||||
postEnableAssetInventory,
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchStatus();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const errorMessage =
|
||||
mutation.error?.body?.message ||
|
||||
i18n.translate('xpack.securitySolution.assetInventory.onboarding.enableAssetInventory.error', {
|
||||
defaultMessage: 'Failed to enable Asset Inventory. Please try again.',
|
||||
});
|
||||
|
||||
// isEnabling is true when the mutation is loading and after it has succeeded so that the UI
|
||||
// can show a loading spinner while the status is being re-fetched
|
||||
const isEnabling = mutation.isLoading || mutation.isSuccess;
|
||||
|
||||
return {
|
||||
isEnabling,
|
||||
error: mutation.isError ? errorMessage : null,
|
||||
reset: mutation.reset,
|
||||
enableAssetInventory: () => mutation.mutate(),
|
||||
};
|
||||
};
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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 type { EuiToolTipProps } from '@elastic/eui';
|
||||
import { EuiToolTip, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
/**
|
||||
* A component that adds a tooltip to its children and adds an underline to indicate that
|
||||
* the children can be hovered for more information.
|
||||
*/
|
||||
export const HoverForExplanationTooltip = ({ children, ...rest }: Partial<EuiToolTipProps>) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<EuiToolTip {...rest}>
|
||||
<u
|
||||
css={css`
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: ${euiTheme.size.xs};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</u>
|
||||
</EuiToolTip>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 { renderHook } from '@testing-library/react';
|
||||
import { useAssetInventoryRoutes } from './use_asset_inventory_routes';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { API_VERSIONS } from '../../../common/constants';
|
||||
|
||||
jest.mock('../../common/lib/kibana');
|
||||
|
||||
describe('useAssetInventoryRoutes', () => {
|
||||
const mockFetch = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
http: { fetch: mockFetch },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the correct endpoint and options for `postEnableAssetInventory`', async () => {
|
||||
mockFetch.mockResolvedValue({ success: true });
|
||||
|
||||
const { result } = renderHook(useAssetInventoryRoutes);
|
||||
await result.current.postEnableAssetInventory();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/asset_inventory/enable', {
|
||||
method: 'POST',
|
||||
version: API_VERSIONS.public.v1,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the correct endpoint and options for `getAssetInventoryStatus`', async () => {
|
||||
mockFetch.mockResolvedValue({ status: 'enabled' });
|
||||
|
||||
const { result } = renderHook(useAssetInventoryRoutes);
|
||||
const response = await result.current.getAssetInventoryStatus();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/asset_inventory/status', {
|
||||
method: 'GET',
|
||||
version: API_VERSIONS.public.v1,
|
||||
query: {},
|
||||
});
|
||||
expect(response).toEqual({ status: 'enabled' });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import {
|
||||
ASSET_INVENTORY_ENABLE_API_PATH,
|
||||
ASSET_INVENTORY_STATUS_API_PATH,
|
||||
} from '../../../common/api/asset_inventory/constants';
|
||||
import type {
|
||||
AssetInventoryEnableResponse,
|
||||
AssetInventoryStatusResponse,
|
||||
} from '../../../common/api/asset_inventory/types';
|
||||
import { API_VERSIONS } from '../../../common/constants';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
|
||||
export const useAssetInventoryRoutes = () => {
|
||||
const http = useKibana().services.http;
|
||||
|
||||
return useMemo(() => {
|
||||
const postEnableAssetInventory = async () => {
|
||||
return http.fetch<AssetInventoryEnableResponse>(ASSET_INVENTORY_ENABLE_API_PATH, {
|
||||
method: 'POST',
|
||||
version: API_VERSIONS.public.v1,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
};
|
||||
|
||||
const getAssetInventoryStatus = async () => {
|
||||
return http.fetch<AssetInventoryStatusResponse>(ASSET_INVENTORY_STATUS_API_PATH, {
|
||||
method: 'GET',
|
||||
version: API_VERSIONS.public.v1,
|
||||
query: {},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
getAssetInventoryStatus,
|
||||
postEnableAssetInventory,
|
||||
};
|
||||
}, [http]);
|
||||
};
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { AssetInventoryStatusResponse } from '../../../common/api/asset_inventory/types';
|
||||
import { useAssetInventoryRoutes } from './use_asset_inventory_routes';
|
||||
|
||||
const ASSET_INVENTORY_STATUS_KEY = ['GET', 'ASSET_INVENTORY_STATUS'];
|
||||
|
||||
export const useAssetInventoryStatus = () => {
|
||||
const { getAssetInventoryStatus } = useAssetInventoryRoutes();
|
||||
|
||||
return useQuery<AssetInventoryStatusResponse>({
|
||||
queryKey: ASSET_INVENTORY_STATUS_KEY,
|
||||
queryFn: () => getAssetInventoryStatus(),
|
||||
refetchInterval: (data) => {
|
||||
if (data?.status === 'ready') {
|
||||
return false;
|
||||
}
|
||||
return 3000;
|
||||
},
|
||||
refetchOnMount: true,
|
||||
});
|
||||
};
|
|
@ -154,7 +154,7 @@ const getEntity = (row: DataTableRecord): EntityEcs => {
|
|||
|
||||
const ASSET_INVENTORY_TABLE_ID = 'asset-inventory-table';
|
||||
|
||||
const AllAssets = ({
|
||||
export const AllAssets = ({
|
||||
nonPersistedFilters,
|
||||
height,
|
||||
hasDistributionBar = true,
|
||||
|
@ -514,6 +514,3 @@ const AllAssets = ({
|
|||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// we need to use default exports to import it via React.lazy
|
||||
export default AllAssets; // eslint-disable-line import/no-default-export
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 { AssetInventoryOnboarding } from '../components/onboarding/asset_inventory_onboarding';
|
||||
import { AllAssets } from './all_assets';
|
||||
|
||||
const AssetInventoryPage = () => {
|
||||
return (
|
||||
<AssetInventoryOnboarding>
|
||||
<AllAssets />
|
||||
</AssetInventoryOnboarding>
|
||||
);
|
||||
};
|
||||
|
||||
AssetInventoryPage.displayName = 'AssetInventoryPage';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default AssetInventoryPage;
|
|
@ -6,7 +6,6 @@
|
|||
*/
|
||||
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { EuiLoadingSpinner } from '@elastic/eui';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { SecuritySubPluginRoutes } from '../app/types';
|
||||
import { SecurityPageName } from '../app/types';
|
||||
|
@ -16,9 +15,10 @@ import { PluginTemplateWrapper } from '../common/components/plugin_template_wrap
|
|||
import { SecurityRoutePageWrapper } from '../common/components/security_route_page_wrapper';
|
||||
import { DataViewContext } from './hooks/data_view_context';
|
||||
import { useDataView } from './hooks/use_asset_inventory_data_table/use_data_view';
|
||||
import { AssetInventoryLoading } from './components/asset_inventory_loading';
|
||||
import { ASSET_INVENTORY_INDEX_PATTERN } from './constants';
|
||||
|
||||
const AllAssetsLazy = lazy(() => import('./pages/all_assets'));
|
||||
const AssetsPageLazy = lazy(() => import('./pages'));
|
||||
|
||||
// Initializing react-query
|
||||
const queryClient = new QueryClient({
|
||||
|
@ -47,8 +47,8 @@ export const AssetInventoryRoutes = () => {
|
|||
<SecurityRoutePageWrapper pageName={SecurityPageName.assetInventory}>
|
||||
<DataViewContext.Provider value={dataViewContextValue}>
|
||||
<SecuritySolutionPageWrapper noPadding>
|
||||
<Suspense fallback={<EuiLoadingSpinner />}>
|
||||
<AllAssetsLazy />
|
||||
<Suspense fallback={<AssetInventoryLoading />}>
|
||||
<AssetsPageLazy />
|
||||
</Suspense>
|
||||
</SecuritySolutionPageWrapper>
|
||||
</DataViewContext.Provider>
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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 { I18nProvider } from '@kbn/i18n-react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
interface TestProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider that wraps the necessary context for testing components.
|
||||
*/
|
||||
export const TestProvider: React.FC<Partial<TestProviderProps>> = ({ children } = {}) => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider>{children}</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const createTestProviderWrapper = () => {
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <TestProvider>{children}</TestProvider>;
|
||||
};
|
||||
};
|
Binary file not shown.
After Width: | Height: | Size: 30 KiB |
|
@ -9,6 +9,7 @@ import type { Logger, IScopedClusterClient } from '@kbn/core/server';
|
|||
|
||||
import type { EntityAnalyticsPrivileges } from '../../../common/api/entity_analytics';
|
||||
import type { GetEntityStoreStatusResponse } from '../../../common/api/entity_analytics/entity_store/status.gen';
|
||||
import type { InitEntityStoreRequestBody } from '../../../common/api/entity_analytics/entity_store/enable.gen';
|
||||
import type { ExperimentalFeatures } from '../../../common';
|
||||
import type { SecuritySolutionApiRequestHandlerContext } from '../..';
|
||||
|
||||
|
@ -43,15 +44,26 @@ export class AssetInventoryDataClient {
|
|||
constructor(private readonly options: AssetInventoryClientOpts) {}
|
||||
|
||||
// Enables the asset inventory by deferring the initialization to avoid blocking the main thread.
|
||||
public async enable() {
|
||||
// Utility function to defer execution to the next tick using setTimeout.
|
||||
const run = <T>(fn: () => Promise<T>) =>
|
||||
new Promise<T>((resolve) => setTimeout(() => fn().then(resolve), 0));
|
||||
public async enable(
|
||||
secSolutionContext: SecuritySolutionApiRequestHandlerContext,
|
||||
requestBodyOverrides: InitEntityStoreRequestBody
|
||||
) {
|
||||
const { logger } = this.options;
|
||||
|
||||
// Defer and execute the initialization process.
|
||||
await run(() => this.init());
|
||||
try {
|
||||
logger.debug(`Enabling asset inventory`);
|
||||
|
||||
return { succeeded: true };
|
||||
const entityStoreEnableResponse = await secSolutionContext
|
||||
.getEntityStoreDataClient()
|
||||
.enable(requestBodyOverrides);
|
||||
|
||||
logger.debug(`Enabled asset inventory`);
|
||||
|
||||
return entityStoreEnableResponse;
|
||||
} catch (err) {
|
||||
logger.error(`Error enabling asset inventory: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Initializes the asset inventory by validating experimental feature flags and triggering asynchronous setup.
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import type { Logger } from '@kbn/core/server';
|
||||
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
import { ASSET_INVENTORY_DELETE_API_PATH } from '../../../../common/api/asset_inventory/constants';
|
||||
import { API_VERSIONS } from '../../../../common/constants';
|
||||
import type { AssetInventoryRoutesDeps } from '../types';
|
||||
|
||||
|
@ -18,7 +19,7 @@ export const deleteAssetInventoryRoute = (
|
|||
router.versioned
|
||||
.delete({
|
||||
access: 'public',
|
||||
path: '/api/asset_inventory/delete',
|
||||
path: ASSET_INVENTORY_DELETE_API_PATH,
|
||||
security: {
|
||||
authz: {
|
||||
requiredPrivileges: ['securitySolution'],
|
||||
|
|
|
@ -8,19 +8,21 @@
|
|||
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
import type { Logger } from '@kbn/core/server';
|
||||
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
|
||||
import { API_VERSIONS } from '../../../../common/constants';
|
||||
|
||||
import type { AssetInventoryRoutesDeps } from '../types';
|
||||
import { InitEntityStoreRequestBody } from '../../../../common/api/entity_analytics/entity_store/enable.gen';
|
||||
import { ASSET_INVENTORY_ENABLE_API_PATH } from '../../../../common/api/asset_inventory/constants';
|
||||
|
||||
export const enableAssetInventoryRoute = (
|
||||
router: AssetInventoryRoutesDeps['router'],
|
||||
logger: Logger,
|
||||
config: AssetInventoryRoutesDeps['config']
|
||||
logger: Logger
|
||||
) => {
|
||||
router.versioned
|
||||
.post({
|
||||
access: 'public',
|
||||
path: '/api/asset_inventory/enable',
|
||||
path: ASSET_INVENTORY_ENABLE_API_PATH,
|
||||
security: {
|
||||
authz: {
|
||||
requiredPrivileges: ['securitySolution'],
|
||||
|
@ -30,8 +32,11 @@ export const enableAssetInventoryRoute = (
|
|||
.addVersion(
|
||||
{
|
||||
version: API_VERSIONS.public.v1,
|
||||
// TODO: create validation
|
||||
validate: false,
|
||||
validate: {
|
||||
request: {
|
||||
body: buildRouteValidationWithZod(InitEntityStoreRequestBody),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async (context, request, response) => {
|
||||
|
@ -39,7 +44,7 @@ export const enableAssetInventoryRoute = (
|
|||
const secSol = await context.securitySolution;
|
||||
|
||||
try {
|
||||
const body = await secSol.getAssetInventoryClient().enable();
|
||||
const body = await secSol.getAssetInventoryClient().enable(secSol, request.body);
|
||||
|
||||
return response.ok({ body });
|
||||
} catch (e) {
|
||||
|
|
|
@ -16,7 +16,7 @@ export const registerAssetInventoryRoutes = ({
|
|||
config,
|
||||
getStartServices,
|
||||
}: AssetInventoryRoutesDeps) => {
|
||||
enableAssetInventoryRoute(router, logger, config);
|
||||
enableAssetInventoryRoute(router, logger);
|
||||
deleteAssetInventoryRoute(router, logger);
|
||||
statusAssetInventoryRoute(router, logger, getStartServices);
|
||||
};
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
import type { Logger } from '@kbn/core/server';
|
||||
import { ASSET_INVENTORY_STATUS_API_PATH } from '../../../../common/api/asset_inventory/constants';
|
||||
import { API_VERSIONS } from '../../../../common/constants';
|
||||
|
||||
import type { AssetInventoryRoutesDeps } from '../types';
|
||||
|
@ -21,7 +22,7 @@ export const statusAssetInventoryRoute = (
|
|||
router.versioned
|
||||
.get({
|
||||
access: 'public',
|
||||
path: '/api/asset_inventory/status',
|
||||
path: ASSET_INVENTORY_STATUS_API_PATH,
|
||||
security: {
|
||||
authz: {
|
||||
requiredPrivileges: ['securitySolution'],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue