From de9d5465df5900936991d79306cb2cbbe63f4623 Mon Sep 17 00:00:00 2001 From: Jatin Kathuria Date: Fri, 29 Nov 2024 16:14:27 +0100 Subject: [PATCH 1/4] [Security Solution] Adds callback `onUpdatePageIndex` to get current `pageIndex` in Unified Data table (#201240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Handles resolution for - Notes fetching data for all Timeline Records which leads to performance issues. - https://github.com/elastic/kibana/issues/201330 ## Issue - Notes fetching data for all Timeline Records Currently, there was no way for consumer of `UnifiedDataGrid` to get the current `pageIndex`. Security Solution needs to get the current `pageIndex` so the items on the current page can be calculated. @elastic/kibana-data-discovery , please let us know if you have any opinion here. This results in notes being fetched for all Timeline Records which means minimum of 500 records and if user has queries 5000 records ( for example ), a request will be made to query notes for all those 5000 notes which leads to performance issue and sometimes error as shown below: ![image](https://github.com/user-attachments/assets/6fcfe05d-340c-4dcb-a273-5af53ed12945) ## 👨‍💻 Changes This adds attribute `pageIndex` to timeline state. ```javascript { "pageIndex": number } ``` `pageIndex` helps with getting the events for that particular page. ## 🟡 Caveat - Currently this `pageIndex` is shared between Query and EQL tabs which can lead to wonky behavior at time. - Additionally, as of now table maintains its own page index and consumer component cannot effect the `pageIndex` of the UnifiedDataGrid. --- .../src/components/data_table.test.tsx | 60 ++++ .../src/components/data_table.tsx | 59 ++-- .../common/types/timeline/store.ts | 2 +- .../body/unified_timeline_body.test.tsx | 42 +-- .../timeline/body/unified_timeline_body.tsx | 28 +- .../timelines/components/timeline/events.ts | 2 +- .../timeline/tabs/eql/index.test.tsx | 266 +++++++++++++++--- .../components/timeline/tabs/eql/index.tsx | 68 +++-- .../components/timeline/tabs/pinned/index.tsx | 37 ++- .../timeline/tabs/query/index.test.tsx | 128 +++++++++ .../components/timeline/tabs/query/index.tsx | 36 ++- .../data_table/index.test.tsx | 2 +- .../unified_components/data_table/index.tsx | 13 +- .../unified_components/index.test.tsx | 3 +- .../timeline/unified_components/index.tsx | 13 +- .../timelines/containers/index.test.tsx | 57 ++-- .../public/timelines/containers/index.tsx | 69 ++++- 17 files changed, 685 insertions(+), 200 deletions(-) diff --git a/packages/kbn-unified-data-table/src/components/data_table.test.tsx b/packages/kbn-unified-data-table/src/components/data_table.test.tsx index f440c2845adaa..3ee4e5a9e7a13 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.test.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.test.tsx @@ -1399,4 +1399,64 @@ describe('UnifiedDataTable', () => { EXTENDED_JEST_TIMEOUT ); }); + + describe('pagination', () => { + const onChangePageMock = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + }); + test('should effect pageIndex change', async () => { + const component = await getComponent({ + ...getProps(), + onUpdatePageIndex: onChangePageMock, + rowsPerPageState: 1, + rowsPerPageOptions: [1, 5], + }); + + expect(findTestSubject(component, 'pagination-button-1').exists()).toBeTruthy(); + onChangePageMock.mockClear(); + findTestSubject(component, 'pagination-button-1').simulate('click'); + expect(onChangePageMock).toHaveBeenNthCalledWith(1, 1); + }); + + test('should effect pageIndex change when itemsPerPage has been changed', async () => { + /* + * Use Case: + * + * Let's say we have 4 pages and we are on page 1 with 1 item per page. + * Now if we change items per page to 4, it should automatically change the pageIndex to 0. + * + * */ + const component = await getComponent({ + ...getProps(), + onUpdatePageIndex: onChangePageMock, + rowsPerPageState: 1, + rowsPerPageOptions: [1, 4], + }); + + expect(findTestSubject(component, 'pagination-button-4').exists()).toBeTruthy(); + onChangePageMock.mockClear(); + // go to last page + findTestSubject(component, 'pagination-button-4').simulate('click'); + expect(onChangePageMock).toHaveBeenNthCalledWith(1, 4); + onChangePageMock.mockClear(); + + // Change items per Page so that pageIndex autoamtically changes. + expect(findTestSubject(component, 'tablePaginationPopoverButton').text()).toBe( + 'Rows per page: 1' + ); + findTestSubject(component, 'tablePaginationPopoverButton').simulate('click'); + component.setProps({ + rowsPerPageState: 5, + }); + + await waitFor(() => { + expect(findTestSubject(component, 'tablePaginationPopoverButton').text()).toBe( + 'Rows per page: 5' + ); + }); + + expect(onChangePageMock).toHaveBeenNthCalledWith(1, 0); + }); + }); }); diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index a22ee8317be2f..7d57188dd1010 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -261,6 +261,12 @@ export interface UnifiedDataTableProps { * Update rows per page state */ onUpdateRowsPerPage?: (rowsPerPage: number) => void; + /** + * + * this callback is triggered when user navigates to a different page + * + */ + onUpdatePageIndex?: (pageIndex: number) => void; /** * Configuration option to limit sample size slider */ @@ -493,6 +499,7 @@ export const UnifiedDataTable = ({ getRowIndicator, dataGridDensityState, onUpdateDataGridDensity, + onUpdatePageIndex, }: UnifiedDataTableProps) => { const { fieldFormats, toastNotifications, dataViewFieldEditor, uiSettings, storage, data } = services; @@ -519,6 +526,8 @@ export const UnifiedDataTable = ({ docIdsInSelectionOrder, } = selectedDocsState; + const [currentPageIndex, setCurrentPageIndex] = useState(0); + useEffect(() => { if (!hasSelectedDocs && isFilterActive) { setIsFilterActive(false); @@ -596,50 +605,56 @@ export const UnifiedDataTable = ({ typeof rowsPerPageState === 'number' && rowsPerPageState > 0 ? rowsPerPageState : DEFAULT_ROWS_PER_PAGE; - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: currentPageSize, - }); + const rowCount = useMemo(() => (displayedRows ? displayedRows.length : 0), [displayedRows]); const pageCount = useMemo( - () => Math.ceil(rowCount / pagination.pageSize), - [rowCount, pagination] + () => Math.ceil(rowCount / currentPageSize), + [rowCount, currentPageSize] ); + useEffect(() => { + /** + * Syncs any changes in pageIndex because of changes in pageCount + * to the consumer. + * + */ + setCurrentPageIndex((previousPageIndex: number) => { + const calculatedPageIndex = previousPageIndex > pageCount - 1 ? 0 : previousPageIndex; + if (calculatedPageIndex !== previousPageIndex) { + onUpdatePageIndex?.(calculatedPageIndex); + } + return calculatedPageIndex; + }); + }, [onUpdatePageIndex, pageCount]); + const paginationObj = useMemo(() => { const onChangeItemsPerPage = (pageSize: number) => { onUpdateRowsPerPage?.(pageSize); }; - const onChangePage = (pageIndex: number) => - setPagination((paginationData) => ({ ...paginationData, pageIndex })); + const onChangePage = (newPageIndex: number) => { + setCurrentPageIndex(newPageIndex); + onUpdatePageIndex?.(newPageIndex); + }; return isPaginationEnabled ? { onChangeItemsPerPage, onChangePage, - pageIndex: pagination.pageIndex > pageCount - 1 ? 0 : pagination.pageIndex, - pageSize: pagination.pageSize, - pageSizeOptions: rowsPerPageOptions ?? getRowsPerPageOptions(pagination.pageSize), + pageIndex: currentPageIndex, + pageSize: currentPageSize, + pageSizeOptions: rowsPerPageOptions ?? getRowsPerPageOptions(currentPageSize), } : undefined; }, [ isPaginationEnabled, - pagination.pageIndex, - pagination.pageSize, - pageCount, rowsPerPageOptions, onUpdateRowsPerPage, + currentPageSize, + currentPageIndex, + onUpdatePageIndex, ]); - useEffect(() => { - setPagination((paginationData) => - paginationData.pageSize === currentPageSize - ? paginationData - : { ...paginationData, pageSize: currentPageSize } - ); - }, [currentPageSize, setPagination]); - const unifiedDataTableContextValue = useMemo( () => ({ expanded: expandedDoc, diff --git a/x-pack/plugins/security_solution/common/types/timeline/store.ts b/x-pack/plugins/security_solution/common/types/timeline/store.ts index c65705e0c9a74..834949d2ed591 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/store.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/store.ts @@ -73,7 +73,7 @@ export type OnColumnRemoved = (columnId: ColumnId) => void; export type OnColumnResized = ({ columnId, delta }: { columnId: ColumnId; delta: number }) => void; /** Invoked when a user clicks to load more item */ -export type OnChangePage = (nextPage: number) => void; +export type OnFetchMoreRecords = (nextPage: number) => void; /** Invoked when a user checks/un-checks a row */ export type OnRowSelected = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx index 41cdec6d6d4bb..77f26075581e0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx @@ -12,7 +12,7 @@ import { UnifiedTimeline } from '../unified_components'; import { defaultUdtHeaders } from './column_headers/default_headers'; import type { UnifiedTimelineBodyProps } from './unified_timeline_body'; import { UnifiedTimelineBody } from './unified_timeline_body'; -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../common/mock'; jest.mock('../unified_components', () => { @@ -32,17 +32,14 @@ const defaultProps: UnifiedTimelineBodyProps = { isTextBasedQuery: false, itemsPerPage: 25, itemsPerPageOptions: [10, 25, 50], - onChangePage: jest.fn(), + onFetchMoreRecords: jest.fn(), refetch: jest.fn(), rowRenderers: [], sort: [], timelineId: 'timeline-1', totalCount: 0, updatedAt: 0, - pageInfo: { - activePage: 0, - querySize: 0, - }, + onUpdatePageIndex: jest.fn(), }; const renderTestComponents = (props?: UnifiedTimelineBodyProps) => { @@ -57,39 +54,6 @@ describe('UnifiedTimelineBody', () => { beforeEach(() => { (UnifiedTimeline as unknown as jest.Mock).mockImplementation(MockUnifiedTimelineComponent); }); - it('should pass correct page rows', () => { - const { rerender } = renderTestComponents(); - - expect(screen.getByTestId('unifiedTimelineBody')).toBeVisible(); - expect(MockUnifiedTimelineComponent).toHaveBeenCalledTimes(2); - - expect(MockUnifiedTimelineComponent).toHaveBeenLastCalledWith( - expect.objectContaining({ - events: mockEventsData.flat(), - }), - {} - ); - - const newEventsData = structuredClone([mockEventsData[0]]); - - const newProps = { - ...defaultProps, - pageInfo: { - activePage: 1, - querySize: 0, - }, - events: newEventsData, - }; - - MockUnifiedTimelineComponent.mockClear(); - rerender(); - expect(MockUnifiedTimelineComponent).toHaveBeenLastCalledWith( - expect.objectContaining({ - events: [...mockEventsData, ...newEventsData].flat(), - }), - {} - ); - }); it('should pass default columns when empty column list is supplied', () => { const newProps = { ...defaultProps, columns: [] }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx index 95feab8543617..b705717fc437f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx @@ -6,23 +6,20 @@ */ import type { ComponentProps, ReactElement } from 'react'; -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { RootDragDropProvider } from '@kbn/dom-drag-drop'; import { StyledTableFlexGroup, StyledUnifiedTableFlexItem } from '../unified_components/styles'; import { UnifiedTimeline } from '../unified_components'; import { defaultUdtHeaders } from './column_headers/default_headers'; -import type { PaginationInputPaginated, TimelineItem } from '../../../../../common/search_strategy'; export interface UnifiedTimelineBodyProps extends ComponentProps { header: ReactElement; - pageInfo: Pick; } export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => { const { header, isSortEnabled, - pageInfo, columns, rowRenderers, timelineId, @@ -33,28 +30,14 @@ export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => { refetch, dataLoadingState, totalCount, - onChangePage, + onFetchMoreRecords, activeTab, updatedAt, trailingControlColumns, leadingControlColumns, + onUpdatePageIndex, } = props; - const [pageRows, setPageRows] = useState([]); - - const rows = useMemo(() => pageRows.flat(), [pageRows]); - - useEffect(() => { - setPageRows((currentPageRows) => { - if (pageInfo.activePage !== 0 && currentPageRows[pageInfo.activePage]?.length) { - return currentPageRows; - } - const newPageRows = pageInfo.activePage === 0 ? [] : [...currentPageRows]; - newPageRows[pageInfo.activePage] = events; - return newPageRows; - }); - }, [events, pageInfo.activePage]); - const columnsHeader = useMemo(() => columns ?? defaultUdtHeaders, [columns]); return ( @@ -73,16 +56,17 @@ export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => { itemsPerPage={itemsPerPage} itemsPerPageOptions={itemsPerPageOptions} sort={sort} - events={rows} + events={events} refetch={refetch} dataLoadingState={dataLoadingState} totalCount={totalCount} - onChangePage={onChangePage} + onFetchMoreRecords={onFetchMoreRecords} activeTab={activeTab} updatedAt={updatedAt} isTextBasedQuery={false} trailingControlColumns={trailingControlColumns} leadingControlColumns={leadingControlColumns} + onUpdatePageIndex={onUpdatePageIndex} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts index 14046b853a235..c48e38ba69a21 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts @@ -13,7 +13,7 @@ export type { OnColumnsSorted, OnColumnRemoved, OnColumnResized, - OnChangePage, + OnFetchMoreRecords as OnChangePage, OnPinEvent, OnRowSelected, OnSelectAll, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.test.tsx index 23fb44d04910f..a71f99715131e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.test.tsx @@ -5,18 +5,16 @@ * 2.0. */ -import React from 'react'; +import type { ComponentProps } from 'react'; +import React, { useEffect } from 'react'; import useResizeObserver from 'use-resize-observer/polyfilled'; -import type { Dispatch } from 'redux'; -import { defaultRowRenderers } from '../../body/renderers'; -import { DefaultCellRenderer } from '../../cell_rendering/default_cell_renderer'; -import { defaultHeaders, mockTimelineData } from '../../../../../common/mock'; +import { createMockStore, mockGlobalState, mockTimelineData } from '../../../../../common/mock'; import { TestProviders } from '../../../../../common/mock/test_providers'; import type { Props as EqlTabContentComponentProps } from '.'; -import { EqlTabContentComponent } from '.'; -import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline'; +import EqlTabContentComponent from '.'; +import { TimelineId } from '../../../../../../common/types/timeline'; import { useTimelineEvents } from '../../../../containers'; import { useTimelineEventsDetails } from '../../../../containers/details'; import { useSourcererDataView } from '../../../../../sourcerer/containers'; @@ -24,7 +22,15 @@ import { mockSourcererScope } from '../../../../../sourcerer/containers/mocks'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import type { ExperimentalFeatures } from '../../../../../../common'; import { allowedExperimentalValues } from '../../../../../../common'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import * as notesApi from '../../../../../notes/api/api'; +import { timelineActions } from '../../../../store'; +import { DefaultCellRenderer } from '../../cell_rendering/default_cell_renderer'; +import { defaultRowRenderers } from '../../body/renderers'; +import { useDispatch } from 'react-redux'; +import { TimelineTabs } from '@kbn/securitysolution-data-table'; + +const SPECIAL_TEST_TIMEOUT = 30000; jest.mock('../../../../containers', () => ({ useTimelineEvents: jest.fn(), @@ -50,10 +56,43 @@ mockUseResizeObserver.mockImplementation(() => ({})); jest.mock('../../../../../common/lib/kibana'); +let useTimelineEventsMock = jest.fn(); + +const loadPageMock = jest.fn(); + +const mockState = { + ...structuredClone(mockGlobalState), +}; +mockState.timeline.timelineById[TimelineId.test].activeTab = TimelineTabs.eql; + +const TestComponent = (props: Partial>) => { + const testComponentDefaultProps: ComponentProps = { + timelineId: TimelineId.test, + renderCellValue: DefaultCellRenderer, + rowRenderers: defaultRowRenderers, + }; + + const dispatch = useDispatch(); + + useEffect(() => { + // Unified field list can be a culprit for long load times, so we wait for the timeline to be interacted with to load + dispatch(timelineActions.showTimeline({ id: TimelineId.test, show: true })); + + // populating timeline so that it is not blank + dispatch( + timelineActions.updateEqlOptions({ + id: TimelineId.test, + field: 'query', + value: 'any where true', + }) + ); + }, [dispatch]); + + return ; +}; + describe('EQL Tab', () => { - let props = {} as EqlTabContentComponentProps; - const startDate = '2018-03-23T18:49:23.132Z'; - const endDate = '2018-03-24T03:33:52.253Z'; + const props = {} as EqlTabContentComponentProps; beforeAll(() => { // https://github.com/atlassian/react-beautiful-dnd/blob/4721a518356f72f1dac45b5fd4ee9d466aa2996b/docs/guides/setup-problem-detection-and-error-recovery.md#disable-logging @@ -65,7 +104,7 @@ describe('EQL Tab', () => { }); beforeEach(() => { - (useTimelineEvents as jest.Mock).mockReturnValue([ + useTimelineEventsMock = jest.fn(() => [ false, { events: mockTimelineData.slice(0, 1), @@ -75,6 +114,7 @@ describe('EQL Tab', () => { }, }, ]); + (useTimelineEvents as jest.Mock).mockImplementation(useTimelineEventsMock); (useTimelineEventsDetails as jest.Mock).mockReturnValue([false, {}]); (useSourcererDataView as jest.Mock).mockReturnValue(mockSourcererScope); @@ -85,30 +125,23 @@ describe('EQL Tab', () => { } ); - props = { - dispatch: {} as Dispatch, - activeTab: TimelineTabs.eql, - columns: defaultHeaders, - end: endDate, - eqlOptions: {}, - isLive: false, - itemsPerPage: 5, - itemsPerPageOptions: [5, 10, 20], - renderCellValue: DefaultCellRenderer, - rowRenderers: defaultRowRenderers, - start: startDate, - timelineId: TimelineId.test, - timerangeKind: 'absolute', - pinnedEventIds: {}, - eventIdToNoteIds: {}, - }; + HTMLElement.prototype.getBoundingClientRect = jest.fn(() => { + return { + width: 1000, + height: 1000, + x: 0, + y: 0, + } as DOMRect; + }); }); describe('rendering', () => { + const fetchNotesMock = jest.spyOn(notesApi, 'fetchNotesByDocumentIds'); test('should render the timeline table', async () => { + fetchNotesMock.mockImplementation(jest.fn()); render( - - + + ); @@ -117,8 +150,8 @@ describe('EQL Tab', () => { test('it renders the timeline column headers', async () => { render( - - + + ); @@ -138,12 +171,175 @@ describe('EQL Tab', () => { ]); render( - - + + ); expect(await screen.findByText('No results found')).toBeVisible(); }); + + describe('pagination', () => { + beforeEach(() => { + // pagination tests need more than 1 record so here + // we return 5 records instead of just 1. + useTimelineEventsMock = jest.fn(() => [ + false, + { + events: structuredClone(mockTimelineData.slice(0, 5)), + pageInfo: { + activePage: 0, + totalPages: 5, + }, + refreshedAt: Date.now(), + /* + * `totalCount` could be any number w.r.t this test + * and actually means total hits on elastic search + * and not the fecthed number of records. + * + * This helps in testing `sampleSize` and `loadMore` + */ + totalCount: 50, + loadPage: loadPageMock, + }, + ]); + + (useTimelineEvents as jest.Mock).mockImplementation(useTimelineEventsMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it( + 'should load notes for current page only', + async () => { + const mockStateWithNoteInTimeline = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + [TimelineId.test]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + /* 1 record for each page */ + activeTab: TimelineTabs.eql, + itemsPerPage: 1, + itemsPerPageOptions: [1, 2, 3, 4, 5], + savedObjectId: 'timeline-1', // match timelineId in mocked notes data + pinnedEventIds: { '1': true }, + }, + }, + }, + }; + + render( + + + + ); + + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + expect(screen.getByTestId('pagination-button-previous')).toBeVisible(); + + expect(screen.getByTestId('pagination-button-0')).toHaveAttribute('aria-current', 'true'); + expect(fetchNotesMock).toHaveBeenCalledWith(['1']); + + // Page : 2 + + fetchNotesMock.mockClear(); + expect(screen.getByTestId('pagination-button-1')).toBeVisible(); + + fireEvent.click(screen.getByTestId('pagination-button-1')); + + await waitFor(() => { + expect(screen.getByTestId('pagination-button-1')).toHaveAttribute( + 'aria-current', + 'true' + ); + + expect(fetchNotesMock).toHaveBeenNthCalledWith(1, [mockTimelineData[1]._id]); + }); + + // Page : 3 + + fetchNotesMock.mockClear(); + expect(screen.getByTestId('pagination-button-2')).toBeVisible(); + fireEvent.click(screen.getByTestId('pagination-button-2')); + + await waitFor(() => { + expect(screen.getByTestId('pagination-button-2')).toHaveAttribute( + 'aria-current', + 'true' + ); + + expect(fetchNotesMock).toHaveBeenNthCalledWith(1, [mockTimelineData[2]._id]); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + + it( + 'should load notes for correct page size', + async () => { + const mockStateWithNoteInTimeline = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + [TimelineId.test]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + /* 1 record for each page */ + itemsPerPage: 1, + pageIndex: 0, + itemsPerPageOptions: [1, 2, 3, 4, 5], + savedObjectId: 'timeline-1', // match timelineId in mocked notes data + pinnedEventIds: { '1': true }, + }, + }, + }, + }; + + render( + + + + ); + + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + expect(screen.getByTestId('pagination-button-previous')).toBeVisible(); + + expect(screen.getByTestId('pagination-button-0')).toHaveAttribute('aria-current', 'true'); + expect(screen.getByTestId('tablePaginationPopoverButton')).toHaveTextContent( + 'Rows per page: 1' + ); + fireEvent.click(screen.getByTestId('tablePaginationPopoverButton')); + + await waitFor(() => { + expect(screen.getByTestId('tablePagination-2-rows')).toBeVisible(); + }); + + fetchNotesMock.mockClear(); + fireEvent.click(screen.getByTestId('tablePagination-2-rows')); + + await waitFor(() => { + expect(fetchNotesMock).toHaveBeenNthCalledWith(1, [ + mockTimelineData[0]._id, + mockTimelineData[1]._id, + ]); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.tsx index 22289d090ab39..0b2de48e89693 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/eql/index.tsx @@ -7,7 +7,7 @@ import { EuiFlexGroup } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { ConnectedProps } from 'react-redux'; import { connect } from 'react-redux'; import deepEqual from 'fast-deep-equal'; @@ -17,6 +17,7 @@ import type { EuiDataGridControlColumn } from '@elastic/eui'; import { DataLoadingState } from '@kbn/unified-data-table'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; +import { useFetchNotes } from '../../../../../notes/hooks/use_fetch_notes'; import { InputsModelId } from '../../../../../common/store/inputs/constants'; import { useKibana } from '../../../../../common/lib/kibana'; import { @@ -65,6 +66,13 @@ export const EqlTabContentComponent: React.FC = ({ pinnedEventIds, eventIdToNoteIds, }) => { + /* + * Needs to be maintained for each table in each tab independently + * and consequently it cannot be the part of common redux state + * of the timeline. + * + */ + const [pageIndex, setPageIndex] = useState(0); const { telemetry } = useKibana().services; const { query: eqlQuery = '', ...restEqlOption } = eqlOptions; const { portalNode: eqlEventsCountPortalNode } = useEqlEventsCountPortal(); @@ -97,24 +105,42 @@ export const EqlTabContentComponent: React.FC = ({ [end, isBlankTimeline, loadingSourcerer, start] ); - const [ - dataLoadingState, - { events, inspect, totalCount, pageInfo, loadPage, refreshedAt, refetch }, - ] = useTimelineEvents({ - dataViewId, - endDate: end, - eqlOptions: restEqlOption, - fields: timelineQueryFieldsFromColumns, - filterQuery: eqlQuery ?? '', - id: timelineId, - indexNames: selectedPatterns, - language: 'eql', - limit: sampleSize, - runtimeMappings: sourcererDataView.runtimeFieldMap as RunTimeMappings, - skip: !canQueryTimeline(), - startDate: start, - timerangeKind, - }); + const [dataLoadingState, { events, inspect, totalCount, loadPage, refreshedAt, refetch }] = + useTimelineEvents({ + dataViewId, + endDate: end, + eqlOptions: restEqlOption, + fields: timelineQueryFieldsFromColumns, + filterQuery: eqlQuery ?? '', + id: timelineId, + indexNames: selectedPatterns, + language: 'eql', + limit: sampleSize, + runtimeMappings: sourcererDataView.runtimeFieldMap as RunTimeMappings, + skip: !canQueryTimeline(), + startDate: start, + timerangeKind, + }); + + const { onLoad: loadNotesOnEventsLoad } = useFetchNotes(); + + useEffect(() => { + // This useEffect loads the notes only for the events on the current + // page. + const eventsOnCurrentPage = events.slice( + itemsPerPage * pageIndex, + itemsPerPage * (pageIndex + 1) + ); + + loadNotesOnEventsLoad(eventsOnCurrentPage); + }, [events, pageIndex, itemsPerPage, loadNotesOnEventsLoad]); + + /** + * + * Triggers on Datagrid page change + * + */ + const onUpdatePageIndex = useCallback((newPageIndex: number) => setPageIndex(newPageIndex), []); const { openFlyout } = useExpandableFlyoutApi(); const securitySolutionNotesDisabled = useIsExperimentalFeatureEnabled( @@ -263,12 +289,12 @@ export const EqlTabContentComponent: React.FC = ({ refetch={refetch} dataLoadingState={dataLoadingState} totalCount={isBlankTimeline ? 0 : totalCount} - onChangePage={loadPage} + onFetchMoreRecords={loadPage} activeTab={activeTab} updatedAt={refreshedAt} isTextBasedQuery={false} - pageInfo={pageInfo} leadingControlColumns={leadingControlColumns as EuiDataGridControlColumn[]} + onUpdatePageIndex={onUpdatePageIndex} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx index 0b2553d23ac5e..8c0ecbeecfdcc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx @@ -6,13 +6,14 @@ */ import { isEmpty } from 'lodash/fp'; -import React, { useMemo, useCallback, memo } from 'react'; +import React, { useMemo, useCallback, memo, useState, useEffect } from 'react'; import type { ConnectedProps } from 'react-redux'; import { connect } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import type { EuiDataGridControlColumn } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; +import { useFetchNotes } from '../../../../../notes/hooks/use_fetch_notes'; import { DocumentDetailsLeftPanelKey, DocumentDetailsRightPanelKey, @@ -68,6 +69,14 @@ export const PinnedTabContentComponent: React.FC = ({ sort, eventIdToNoteIds, }) => { + /* + * Needs to be maintained for each table in each tab independently + * and consequently it cannot be the part of common redux state + * of the timeline. + * + */ + const [pageIndex, setPageIndex] = useState(0); + const { telemetry } = useKibana().services; const { dataViewId, sourcererDataView, selectedPatterns } = useSourcererDataView( SourcererScopeName.timeline @@ -130,7 +139,7 @@ export const PinnedTabContentComponent: React.FC = ({ ); const { augmentedColumnHeaders } = useTimelineColumns(columns); - const [queryLoadingState, { events, totalCount, pageInfo, loadPage, refreshedAt, refetch }] = + const [queryLoadingState, { events, totalCount, loadPage, refreshedAt, refetch }] = useTimelineEvents({ endDate: '', id: `pinned-${timelineId}`, @@ -146,6 +155,26 @@ export const PinnedTabContentComponent: React.FC = ({ timerangeKind: undefined, }); + const { onLoad: loadNotesOnEventsLoad } = useFetchNotes(); + + useEffect(() => { + // This useEffect loads the notes only for the events on the current + // page. + const eventsOnCurrentPage = events.slice( + itemsPerPage * pageIndex, + itemsPerPage * (pageIndex + 1) + ); + + loadNotesOnEventsLoad(eventsOnCurrentPage); + }, [events, pageIndex, itemsPerPage, loadNotesOnEventsLoad]); + + /** + * + * Triggers on Datagrid page change + * + */ + const onUpdatePageIndex = useCallback((newPageIndex: number) => setPageIndex(newPageIndex), []); + const { openFlyout } = useExpandableFlyoutApi(); const securitySolutionNotesDisabled = useIsExperimentalFeatureEnabled( 'securitySolutionNotesDisabled' @@ -257,13 +286,13 @@ export const PinnedTabContentComponent: React.FC = ({ refetch={refetch} dataLoadingState={queryLoadingState} totalCount={totalCount} - onChangePage={loadPage} + onFetchMoreRecords={loadPage} activeTab={TimelineTabs.pinned} updatedAt={refreshedAt} isTextBasedQuery={false} - pageInfo={pageInfo} leadingControlColumns={leadingControlColumns as EuiDataGridControlColumn[]} trailingControlColumns={rowDetailColumn} + onUpdatePageIndex={onUpdatePageIndex} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx index f0a2c06bbffb4..cfd8f86af9dac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.test.tsx @@ -41,6 +41,7 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { createExpandableFlyoutApiMock } from '../../../../../common/mock/expandable_flyout'; import { OPEN_FLYOUT_BUTTON_TEST_ID } from '../../../../../notes/components/test_ids'; import { userEvent } from '@testing-library/user-event'; +import * as notesApi from '../../../../../notes/api/api'; jest.mock('../../../../../common/components/user_privileges'); @@ -154,7 +155,9 @@ const { storage: storageMock } = createSecuritySolutionStorageMock(); let useTimelineEventsMock = jest.fn(); describe('query tab with unified timeline', () => { + const fetchNotesMock = jest.spyOn(notesApi, 'fetchNotesByDocumentIds'); beforeAll(() => { + fetchNotesMock.mockImplementation(jest.fn()); jest.mocked(useExpandableFlyoutApi).mockImplementation(() => ({ ...createExpandableFlyoutApiMock(), openFlyout: mockOpenFlyout, @@ -176,6 +179,7 @@ describe('query tab with unified timeline', () => { afterEach(() => { jest.clearAllMocks(); storageMock.clear(); + fetchNotesMock.mockClear(); cleanup(); localStorage.clear(); }); @@ -424,6 +428,130 @@ describe('query tab with unified timeline', () => { }, SPECIAL_TEST_TIMEOUT ); + + it( + 'should load notes for current page only', + async () => { + const mockStateWithNoteInTimeline = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + [TimelineId.test]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + /* 1 record for each page */ + itemsPerPage: 1, + pageIndex: 0, + itemsPerPageOptions: [1, 2, 3, 4, 5], + savedObjectId: 'timeline-1', // match timelineId in mocked notes data + pinnedEventIds: { '1': true }, + }, + }, + }, + }; + + render( + + + + ); + + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + expect(screen.getByTestId('pagination-button-previous')).toBeVisible(); + + expect(screen.getByTestId('pagination-button-0')).toHaveAttribute('aria-current', 'true'); + expect(fetchNotesMock).toHaveBeenCalledWith(['1']); + + // Page : 2 + + fetchNotesMock.mockClear(); + expect(screen.getByTestId('pagination-button-1')).toBeVisible(); + + fireEvent.click(screen.getByTestId('pagination-button-1')); + + await waitFor(() => { + expect(screen.getByTestId('pagination-button-1')).toHaveAttribute('aria-current', 'true'); + + expect(fetchNotesMock).toHaveBeenNthCalledWith(1, [mockTimelineData[1]._id]); + }); + + // Page : 3 + + fetchNotesMock.mockClear(); + expect(screen.getByTestId('pagination-button-2')).toBeVisible(); + fireEvent.click(screen.getByTestId('pagination-button-2')); + + await waitFor(() => { + expect(screen.getByTestId('pagination-button-2')).toHaveAttribute('aria-current', 'true'); + + expect(fetchNotesMock).toHaveBeenNthCalledWith(1, [mockTimelineData[2]._id]); + }); + }, + SPECIAL_TEST_TIMEOUT + ); + + it( + 'should load notes for correct page size', + async () => { + const mockStateWithNoteInTimeline = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + [TimelineId.test]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + /* 1 record for each page */ + itemsPerPage: 1, + pageIndex: 0, + itemsPerPageOptions: [1, 2, 3, 4, 5], + savedObjectId: 'timeline-1', // match timelineId in mocked notes data + pinnedEventIds: { '1': true }, + }, + }, + }, + }; + + render( + + + + ); + + expect(await screen.findByTestId('discoverDocTable')).toBeVisible(); + + expect(screen.getByTestId('pagination-button-previous')).toBeVisible(); + + expect(screen.getByTestId('pagination-button-0')).toHaveAttribute('aria-current', 'true'); + expect(screen.getByTestId('tablePaginationPopoverButton')).toHaveTextContent( + 'Rows per page: 1' + ); + fireEvent.click(screen.getByTestId('tablePaginationPopoverButton')); + + await waitFor(() => { + expect(screen.getByTestId('tablePagination-2-rows')).toBeVisible(); + }); + + fetchNotesMock.mockClear(); + fireEvent.click(screen.getByTestId('tablePagination-2-rows')); + + await waitFor(() => { + expect(fetchNotesMock).toHaveBeenNthCalledWith(1, [ + mockTimelineData[0]._id, + mockTimelineData[1]._id, + ]); + }); + }, + SPECIAL_TEST_TIMEOUT + ); }); describe('columns', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx index 967253a34a71a..f614290fd6a5a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx @@ -6,7 +6,7 @@ */ import { isEmpty } from 'lodash/fp'; -import React, { useMemo, useEffect, useCallback } from 'react'; +import React, { useMemo, useEffect, useCallback, useState } from 'react'; import type { ConnectedProps } from 'react-redux'; import { connect, useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; @@ -15,6 +15,7 @@ import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { DataLoadingState } from '@kbn/unified-data-table'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; +import { useFetchNotes } from '../../../../../notes/hooks/use_fetch_notes'; import { DocumentDetailsLeftPanelKey, DocumentDetailsRightPanelKey, @@ -92,6 +93,13 @@ export const QueryTabContentComponent: React.FC = ({ selectedPatterns, sourcererDataView, } = useSourcererDataView(SourcererScopeName.timeline); + /* + * `pageIndex` needs to be maintained for each table in each tab independently + * and consequently it cannot be the part of common redux state + * of the timeline. + * + */ + const [pageIndex, setPageIndex] = useState(0); const { uiSettings, telemetry, timelineDataService } = useKibana().services; const { @@ -167,7 +175,7 @@ export const QueryTabContentComponent: React.FC = ({ const [ dataLoadingState, - { events, inspect, totalCount, pageInfo, loadPage, refreshedAt, refetch }, + { events, inspect, totalCount, loadPage: loadNextEventBatch, refreshedAt, refetch }, ] = useTimelineEvents({ dataViewId, endDate: end, @@ -184,6 +192,26 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, }); + const { onLoad: loadNotesOnEventsLoad } = useFetchNotes(); + + useEffect(() => { + // This useEffect loads the notes only for the events on the current + // page. + const eventsOnCurrentPage = events.slice( + itemsPerPage * pageIndex, + itemsPerPage * (pageIndex + 1) + ); + + loadNotesOnEventsLoad(eventsOnCurrentPage); + }, [events, pageIndex, itemsPerPage, loadNotesOnEventsLoad]); + + /** + * + * Triggers on Datagrid page change + * + */ + const onUpdatePageIndex = useCallback((newPageIndex: number) => setPageIndex(newPageIndex), []); + const { openFlyout } = useExpandableFlyoutApi(); const securitySolutionNotesDisabled = useIsExperimentalFeatureEnabled( 'securitySolutionNotesDisabled' @@ -355,11 +383,11 @@ export const QueryTabContentComponent: React.FC = ({ dataLoadingState={dataLoadingState} totalCount={isBlankTimeline ? 0 : totalCount} leadingControlColumns={leadingControlColumns as EuiDataGridControlColumn[]} - onChangePage={loadPage} + onFetchMoreRecords={loadNextEventBatch} activeTab={activeTab} updatedAt={refreshedAt} isTextBasedQuery={false} - pageInfo={pageInfo} + onUpdatePageIndex={onUpdatePageIndex} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx index 649817d5f8ef2..3f24fc8df4aa9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx @@ -72,7 +72,7 @@ const TestComponent = (props: TestComponentProps) => { refetch={refetchMock} dataLoadingState={DataLoadingState.loaded} totalCount={mockTimelineData.length} - onChangePage={onChangePageMock} + onFetchMoreRecords={onChangePageMock} updatedAt={Date.now()} onSetColumns={jest.fn()} onFilter={jest.fn()} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx index fa5b83f23576a..99e00547f1c33 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx @@ -30,7 +30,7 @@ import type { TimelineItem } from '../../../../../../common/search_strategy'; import { useKibana } from '../../../../../common/lib/kibana'; import type { ColumnHeaderOptions, - OnChangePage, + OnFetchMoreRecords, RowRenderer, TimelineTabs, } from '../../../../../../common/types/timeline'; @@ -64,7 +64,7 @@ type CommonDataTableProps = { refetch: inputsModel.Refetch; onFieldEdited: () => void; totalCount: number; - onChangePage: OnChangePage; + onFetchMoreRecords: OnFetchMoreRecords; activeTab: TimelineTabs; dataLoadingState: DataLoadingState; updatedAt: number; @@ -79,6 +79,7 @@ type CommonDataTableProps = { | 'renderCustomGridBody' | 'trailingControlColumns' | 'isSortEnabled' + | 'onUpdatePageIndex' >; interface DataTableProps extends CommonDataTableProps { @@ -102,13 +103,14 @@ export const TimelineDataTableComponent: React.FC = memo( refetch, dataLoadingState, totalCount, - onChangePage, + onFetchMoreRecords, updatedAt, isTextBasedQuery = false, onSetColumns, onSort, onFilter, leadingControlColumns, + onUpdatePageIndex, }) { const dispatch = useDispatch(); @@ -235,9 +237,9 @@ export const TimelineDataTableComponent: React.FC = memo( ); const handleFetchMoreRecords = useCallback(() => { - onChangePage(fetchedPage + 1); + onFetchMoreRecords(fetchedPage + 1); setFechedPage(fetchedPage + 1); - }, [fetchedPage, onChangePage]); + }, [fetchedPage, onFetchMoreRecords]); const additionalControls = useMemo( () => , @@ -424,6 +426,7 @@ export const TimelineDataTableComponent: React.FC = memo( renderCustomGridBody={finalRenderCustomBodyCallback} trailingControlColumns={finalTrailControlColumns} externalControlColumns={leadingControlColumns} + onUpdatePageIndex={onUpdatePageIndex} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx index c660893ba379e..7d9bde02259a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx @@ -107,10 +107,11 @@ const TestComponent = ( events: localMockedTimelineData, refetch: jest.fn(), totalCount: localMockedTimelineData.length, - onChangePage: jest.fn(), + onFetchMoreRecords: jest.fn(), dataLoadingState: DataLoadingState.loaded, updatedAt: Date.now(), isTextBasedQuery: false, + onUpdatePageIndex: jest.fn(), }; const dispatch = useDispatch(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx index 112886f93ca32..d350b4b530808 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx @@ -12,7 +12,7 @@ import { useDispatch } from 'react-redux'; import { generateFilters } from '@kbn/data-plugin/public'; import type { DataView, DataViewField } from '@kbn/data-plugin/common'; import type { SortOrder } from '@kbn/saved-search-plugin/public'; -import type { DataLoadingState } from '@kbn/unified-data-table'; +import type { DataLoadingState, UnifiedDataTableProps } from '@kbn/unified-data-table'; import { useColumns } from '@kbn/unified-data-table'; import { popularizeField } from '@kbn/unified-data-table/src/utils/popularize_field'; import type { DropType } from '@kbn/dom-drag-drop'; @@ -33,7 +33,7 @@ import type { TimelineItem } from '../../../../../common/search_strategy'; import { useKibana } from '../../../../common/lib/kibana'; import type { ColumnHeaderOptions, - OnChangePage, + OnFetchMoreRecords, RowRenderer, SortColumnTimeline, TimelineTabs, @@ -106,7 +106,7 @@ interface Props { events: TimelineItem[]; refetch: inputsModel.Refetch; totalCount: number; - onChangePage: OnChangePage; + onFetchMoreRecords: OnFetchMoreRecords; activeTab: TimelineTabs; dataLoadingState: DataLoadingState; updatedAt: number; @@ -114,6 +114,7 @@ interface Props { dataView: DataView; trailingControlColumns?: EuiDataGridProps['trailingControlColumns']; leadingControlColumns?: EuiDataGridProps['leadingControlColumns']; + onUpdatePageIndex?: UnifiedDataTableProps['onUpdatePageIndex']; } const UnifiedTimelineComponent: React.FC = ({ @@ -129,12 +130,13 @@ const UnifiedTimelineComponent: React.FC = ({ refetch, dataLoadingState, totalCount, - onChangePage, + onFetchMoreRecords, updatedAt, isTextBasedQuery, dataView, trailingControlColumns, leadingControlColumns, + onUpdatePageIndex, }) => { const dispatch = useDispatch(); const unifiedFieldListContainerRef = useRef(null); @@ -435,13 +437,14 @@ const UnifiedTimelineComponent: React.FC = ({ onFieldEdited={onFieldEdited} dataLoadingState={dataLoadingState} totalCount={totalCount} - onChangePage={onChangePage} + onFetchMoreRecords={onFetchMoreRecords} activeTab={activeTab} updatedAt={updatedAt} isTextBasedQuery={isTextBasedQuery} onFilter={onAddFilter as DocViewFilterFn} trailingControlColumns={trailingControlColumns} leadingControlColumns={leadingControlColumns} + onUpdatePageIndex={onUpdatePageIndex} /> diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx index f00ca0551a9a3..822740f3b9978 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx @@ -30,7 +30,7 @@ jest.mock('../../notes/hooks/use_fetch_notes'); const onLoadMock = jest.fn(); const useFetchNotesMock = useFetchNotes as jest.Mock; -const mockEvents = mockTimelineData.filter((i, index) => index <= 11); +const mockEvents = mockTimelineData.slice(0, 10); const mockSearch = jest.fn(); @@ -70,13 +70,13 @@ jest.mock('../../common/lib/kibana', () => ({ }, edges: mockEvents.map((item) => ({ node: item })), pageInfo: { - activePage: 0, + activePage: args.pagination.activePage, totalPages: 10, }, rawResponse: {}, totalCount: mockTimelineData.length, }); - }, 0); + }, 50); return { unsubscribe: jest.fn() }; }), }; @@ -124,12 +124,12 @@ describe('useTimelineEvents', () => { const endDate: string = '3000-01-01T00:00:00.000Z'; const props: UseTimelineEventsProps = { dataViewId: 'data-view-id', - endDate: '', + endDate, id: TimelineId.active, indexNames: ['filebeat-*'], fields: ['@timestamp', 'event.kind'], filterQuery: '', - startDate: '', + startDate, limit: 25, runtimeMappings: {}, sort: initSortDefault, @@ -166,10 +166,9 @@ describe('useTimelineEvents', () => { >((args) => useTimelineEvents(args), { initialProps: props, }); - // useEffect on params request await waitFor(() => new Promise((resolve) => resolve(null))); - rerender({ ...props, startDate, endDate }); + rerender({ ...props, startDate: '', endDate: '' }); // useEffect on params request await waitFor(() => { expect(mockSearch).toHaveBeenCalledTimes(2); @@ -197,12 +196,6 @@ describe('useTimelineEvents', () => { initialProps: props, }); - // useEffect on params request - await waitFor(() => new Promise((resolve) => resolve(null))); - rerender({ ...props, startDate, endDate }); - // useEffect on params request - await waitFor(() => new Promise((resolve) => resolve(null))); - mockUseRouteSpy.mockReturnValue([ { pageName: SecurityPageName.timelines, @@ -213,7 +206,13 @@ describe('useTimelineEvents', () => { }, ]); - expect(mockSearch).toHaveBeenCalledTimes(2); + rerender({ ...props, startDate, endDate }); + + await waitFor(() => { + expect(result.current[0]).toEqual(DataLoadingState.loaded); + }); + + expect(mockSearch).toHaveBeenCalledTimes(1); expect(result.current).toEqual([ DataLoadingState.loaded, @@ -283,7 +282,7 @@ describe('useTimelineEvents', () => { // useEffect on params request await waitFor(() => new Promise((resolve) => resolve(null))); - expect(mockSearch).toHaveBeenCalledTimes(2); + expect(mockSearch).toHaveBeenCalledTimes(1); mockSearch.mockClear(); rerender({ @@ -307,7 +306,7 @@ describe('useTimelineEvents', () => { // useEffect on params request await waitFor(() => new Promise((resolve) => resolve(null))); - expect(mockSearch).toHaveBeenCalledTimes(2); + expect(mockSearch).toHaveBeenCalledTimes(1); mockSearch.mockClear(); rerender({ ...props, startDate, endDate, fields: ['@timestamp'] }); @@ -325,7 +324,7 @@ describe('useTimelineEvents', () => { // useEffect on params request await waitFor(() => new Promise((resolve) => resolve(null))); - expect(mockSearch).toHaveBeenCalledTimes(2); + expect(mockSearch).toHaveBeenCalledTimes(1); mockSearch.mockClear(); // remove `event.kind` from default fields @@ -343,16 +342,22 @@ describe('useTimelineEvents', () => { await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(0)); }); - describe('Fetch Notes', () => { - test('should call onLoad for notes when events are fetched', async () => { - renderHook((args) => useTimelineEvents(args), { - initialProps: props, - }); + test('should return the combined list of events for all the pages when multiple pages are queried', async () => { + const { result } = renderHook((args) => useTimelineEvents(args), { + initialProps: { ...props }, + }); + await waitFor(() => { + expect(result.current[1].events).toHaveLength(10); + }); - await waitFor(() => { - expect(mockSearch).toHaveBeenCalledTimes(1); - expect(onLoadMock).toHaveBeenNthCalledWith(1, expect.objectContaining(mockEvents)); - }); + result.current[1].loadPage(1); + + await waitFor(() => { + expect(result.current[0]).toEqual(DataLoadingState.loadingMore); + }); + + await waitFor(() => { + expect(result.current[1].events).toHaveLength(20); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 0301f0123c30f..baaed281c7393 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -7,7 +7,7 @@ import deepEqual from 'fast-deep-equal'; import { isEmpty, noop } from 'lodash/fp'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { Subscription } from 'rxjs'; @@ -46,12 +46,21 @@ import type { } from '../../../common/search_strategy/timeline/events/eql'; import { useTrackHttpRequest } from '../../common/lib/apm/use_track_http_request'; import { APP_UI_ID } from '../../../common/constants'; -import { useFetchNotes } from '../../notes/hooks/use_fetch_notes'; export interface TimelineArgs { events: TimelineItem[]; id: string; inspect: InspectResponse; + + /** + * `loadPage` loads the next page/batch of records. + * This is different from the data grid pages. Data grid pagination is only + * client side and changing data grid pages does not impact this function. + * + * When user manually requests next batch of records, then a next batch is fetched + * irrespective of where user is in Data grid pagination. + * + */ loadPage: LoadPage; pageInfo: Pick; refetch: inputsModel.Refetch; @@ -174,6 +183,15 @@ export const useTimelineEventsHandler = ({ } }, [dispatch, id]); + /** + * `wrappedLoadPage` loads the next page/batch of records. + * This is different from the data grid pages. Data grid pagination is only + * client side and changing data grid pages does not impact this function. + * + * When user manually requests next batch of records, then a next batch is fetched + * irrespective of where user is in Data grid pagination. + * + */ const wrappedLoadPage = useCallback( (newActivePage: number) => { clearSignalsState(); @@ -186,6 +204,12 @@ export const useTimelineEventsHandler = ({ [clearSignalsState, id] ); + useEffect(() => { + return () => { + searchSubscription$.current?.unsubscribe(); + }; + }, []); + const refetchGrid = useCallback(() => { if (refetch.current != null) { refetch.current(); @@ -240,10 +264,12 @@ export const useTimelineEventsHandler = ({ next: (response) => { if (!isRunningResponse(response)) { endTracking('success'); + setLoading(DataLoadingState.loaded); setTimelineResponse((prevResponse) => { const newTimelineResponse = { ...prevResponse, + /**/ events: getTimelineEvents(response.edges), inspect: getInspectResponse(response, prevResponse.inspect), pageInfo: response.pageInfo, @@ -269,6 +295,7 @@ export const useTimelineEventsHandler = ({ }, error: (msg) => { endTracking(abortCtrl.current.signal.aborted ? 'aborted' : 'error'); + setLoading(DataLoadingState.loaded); data.search.showError(msg); searchSubscription$.current.unsubscribe(); @@ -483,8 +510,8 @@ export const useTimelineEvents = ({ sort = initSortDefault, skip = false, timerangeKind, - fetchNotes = true, }: UseTimelineEventsProps): [DataLoadingState, TimelineArgs] => { + const [eventsPerPage, setEventsPerPage] = useState([[]]); const [dataLoadingState, timelineResponse, timelineSearchHandler] = useTimelineEventsHandler({ dataViewId, endDate, @@ -501,19 +528,35 @@ export const useTimelineEvents = ({ skip, timerangeKind, }); - const { onLoad } = useFetchNotes(); - const onTimelineSearchComplete: OnNextResponseHandler = useCallback( - (response) => { - if (fetchNotes) onLoad(response.events); - }, - [fetchNotes, onLoad] - ); + useEffect(() => { + /* + * `timelineSearchHandler` only returns the events for the current page. + * This effect is responsible for storing the events for each page so that + * the combined list of events can be supplied to DataGrid. + * + * */ + setEventsPerPage((prev) => { + const result = [...prev]; + result[timelineResponse.pageInfo.activePage] = timelineResponse.events; + return result; + }); + }, [timelineResponse.events, timelineResponse.pageInfo.activePage]); useEffect(() => { if (!timelineSearchHandler) return; - timelineSearchHandler(onTimelineSearchComplete); - }, [timelineSearchHandler, onTimelineSearchComplete]); + timelineSearchHandler(); + }, [timelineSearchHandler]); + + const combinedEvents = useMemo(() => eventsPerPage.flat(), [eventsPerPage]); + + const combinedResponse = useMemo( + () => ({ + ...timelineResponse, + events: combinedEvents, + }), + [timelineResponse, combinedEvents] + ); - return [dataLoadingState, timelineResponse]; + return [dataLoadingState, combinedResponse]; }; From f3bc7004978cfeca37971c0225f54264f7ff788f Mon Sep 17 00:00:00 2001 From: "elastic-renovate-prod[bot]" <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Date: Fri, 29 Nov 2024 09:21:17 -0600 Subject: [PATCH 2/4] Update dependency @redocly/cli to ^1.25.13 (main) (#202196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | Pending | |---|---|---|---|---| | [@redocly/cli](https://togithub.com/Redocly/redocly-cli) | devDependencies | patch | [`^1.25.12` -> `^1.25.13`](https://renovatebot.com/diffs/npm/@redocly%2fcli/1.25.14/1.25.13) | `1.25.14` | | [@redocly/cli](https://togithub.com/Redocly/redocly-cli) | dependencies | patch | [`^1.25.12` -> `^1.25.13`](https://renovatebot.com/diffs/npm/@redocly%2fcli/1.25.14/1.25.13) | `1.25.14` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://togithub.com/renovatebot/renovate). Co-authored-by: elastic-renovate-prod[bot] <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Co-authored-by: Alejandro Fernández Haro --- oas_docs/package-lock.json | 2 +- oas_docs/package.json | 2 +- package.json | 2 +- yarn.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/oas_docs/package-lock.json b/oas_docs/package-lock.json index c3edcf9a017af..27d9823c1e873 100644 --- a/oas_docs/package-lock.json +++ b/oas_docs/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@redocly/cli": "^1.25.12", + "@redocly/cli": "^1.25.13", "bump-cli": "^2.8.4" } }, diff --git a/oas_docs/package.json b/oas_docs/package.json index 88d55030405b5..f2b89bc6f506a 100644 --- a/oas_docs/package.json +++ b/oas_docs/package.json @@ -8,7 +8,7 @@ }, "dependencies": { "bump-cli": "^2.8.4", - "@redocly/cli": "^1.25.12" + "@redocly/cli": "^1.25.13" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" diff --git a/package.json b/package.json index 99e47203f8c43..35449a04f6ad7 100644 --- a/package.json +++ b/package.json @@ -1513,7 +1513,7 @@ "@octokit/rest": "^17.11.2", "@parcel/watcher": "^2.1.0", "@playwright/test": "=1.46.0", - "@redocly/cli": "^1.25.12", + "@redocly/cli": "^1.25.13", "@statoscope/webpack-plugin": "^5.28.2", "@storybook/addon-a11y": "^6.5.16", "@storybook/addon-actions": "^6.5.16", diff --git a/yarn.lock b/yarn.lock index 5bf9282d9cd88..1902cc1ed33a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9105,7 +9105,7 @@ require-from-string "^2.0.2" uri-js-replace "^1.0.1" -"@redocly/cli@^1.25.12": +"@redocly/cli@^1.25.13": version "1.25.14" resolved "https://registry.yarnpkg.com/@redocly/cli/-/cli-1.25.14.tgz#05810916bac2193137020ffbfa0bd766caca2258" integrity sha512-HRDOoN3YpFe4+2rWrL/uTqRUDqqyrRtj1MVHFJ0heKTfBLOFEEfXXUYExw7R6yoiY3+GnptR96wePeFpH1gheg== From 52fa2766619e113d29c6ac4ff547fa7366e3d617 Mon Sep 17 00:00:00 2001 From: Viduni Wickramarachchi Date: Fri, 29 Nov 2024 10:28:14 -0500 Subject: [PATCH 3/4] [Obs AI Assistant] Remove the navigate-to-conversation button when there are initial messages but no conversationId (#202243) Closes https://github.com/elastic/kibana/issues/198379 ## Summary ### Problem When a conversation is started from contextual insights, `initialMessages` are set and displayed on the `ChatFlyout`. However, when the user clicks on "Navigate to conversations` from the Chat flyout header, a new conversation opens in the `ai_assistant_app`. This is because, even though there are initial messages set, there is no conversation ID until the user interacts with the AI Assistant for the conversation started from contextual insights. In order to navigate to a conversation (to the `ai_assistant_app`), a conversationID is required, if not a new conversation opens up. This behaviour seems a little inconsistent, because the expectation is to have the initial messages displayed on the AI Assistant app too. ### Solution Since we do not have a way to persist these initial messages from contextual insights when navigating to the conversations view (`ai_assistant_app`), the navigate to conversations button is removed. If the user interacts with this conversation, since the conversation will be persisted with a conversationId, "navigate to conversations" will be available. ### Checklist - [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) --- x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx index b8ea673483372..00879b38932aa 100644 --- a/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx +++ b/x-pack/packages/kbn-ai-assistant/src/chat/chat_body.tsx @@ -509,7 +509,9 @@ export function ChatBody({ saveTitle(newTitle); }} onToggleFlyoutPositionMode={onToggleFlyoutPositionMode} - navigateToConversation={navigateToConversation} + navigateToConversation={ + initialMessages?.length && !initialConversationId ? undefined : navigateToConversation + } /> From 06b7993bd90cf84f87a99668c4425fb4dd6d6b5e Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Fri, 29 Nov 2024 16:29:04 +0100 Subject: [PATCH 4/4] [SecuritySolution] Entity Engine status tab (#201235) ## Summary * Add two tabs to the Entity Store page * The import entities tab has all the bulk upload content * The status tab has the new content created on this PR * Move the "clear entity store data" button to the header according to design mockups. * Delete unused stats route * Rename `enablement` API docs to `enable` * Add a new parameter to the status API (`withComponents`) * Should I make it snake cased? ### import entities tab ![Screenshot 2024-11-27 at 15 07 01](https://github.com/user-attachments/assets/c433e217-781e-4792-8695-2ee609efa654) ### status tab ![Screenshot 2024-11-27 at 15 07 20](https://github.com/user-attachments/assets/8970c023-22b3-4e83-a444-fa3ccf78ea42) ## How to test it - Open security solution app with data - Go to entity store page - You shouldn't see the new tab because the engine is disabled - Enable the engine and wait - Click on the new tab that showed up - It should list user and host engine components, and everything should be installed - Delete or misconfigure some of the resources, the new status should be reflected on the tab. ## TODO: - [x] Rebase main after https://github.com/elastic/kibana/pull/199762 is merged - [x] Remove temporary status hook - [x] Fix the"clear entity data" button. It should re-fetch the status API. ### 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/packages/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) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- oas_docs/output/kibana.serverless.yaml | 95 ++++---- oas_docs/output/kibana.yaml | 94 +++++--- .../entity_store/common.gen.ts | 31 +++ .../entity_store/common.schema.yaml | 43 ++++ .../{enablement.gen.ts => enable.gen.ts} | 8 +- ...blement.schema.yaml => enable.schema.yaml} | 21 -- .../entity_store/engine/index.ts | 1 - .../entity_store/engine/stats.gen.ts | 39 ---- .../entity_store/engine/stats.schema.yaml | 41 ---- .../entity_store/status.gen.ts | 43 ++++ .../entity_store/status.schema.yaml | 44 ++++ .../common/api/quickstart_client.gen.ts | 31 +-- ...alytics_api_2023_10_31.bundled.schema.yaml | 96 +++++--- ...alytics_api_2023_10_31.bundled.schema.yaml | 96 +++++--- .../entity_analytics/api/entity_store.ts | 16 +- .../components/dashboard_enablement_panel.tsx | 2 +- .../engine_components_status.test.tsx | 118 ++++++++++ .../components/engine_components_status.tsx | 75 +++++++ .../engines_status/hooks/use_columns.tsx | 184 ++++++++++++++++ .../components/engines_status/index.test.tsx | 100 +++++++++ .../components/engines_status/index.tsx | 103 +++++++++ .../entity_store/hooks/use_entity_store.ts | 31 ++- .../entity_store_management_page.test.tsx | 207 ++++++++++++++++++ .../pages/entity_store_management_page.tsx | 156 +++++++------ .../entity_store/auditing/resources.ts | 18 -- .../component_template.ts | 25 +++ .../elasticsearch_assets/enrich_policy.ts | 19 ++ .../elasticsearch_assets/entity_index.ts | 24 +- .../elasticsearch_assets/ingest_pipeline.ts | 25 +++ .../entity_store/entity_store_data_client.ts | 166 ++++++++++++-- .../entity_store/routes/enablement.ts | 4 +- .../entity_store/routes/stats.ts | 64 ------ .../entity_store/routes/status.ts | 12 +- .../task/field_retention_enrichment_task.ts | 39 +++- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../services/security_solution_api.gen.ts | 25 +-- .../entity_store.ts | 77 +++++-- 39 files changed, 1663 insertions(+), 516 deletions(-) rename x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/{enablement.gen.ts => enable.gen.ts} (78%) rename x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/{enablement.schema.yaml => enable.schema.yaml} (65%) delete mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.gen.ts delete mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.schema.yaml create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/status.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/status.schema.yaml create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/components/engine_components_status.test.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/components/engine_components_status.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/hooks/use_columns.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/index.tsx create mode 100644 x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.test.tsx delete mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/auditing/resources.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 68a5ccaff2e10..959c4d886eee2 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -7560,42 +7560,6 @@ paths: tags: - Security Entity Analytics API x-beta: true - /api/entity_store/engines/{entityType}/stats: - post: - operationId: GetEntityEngineStats - parameters: - - description: The entity type of the engine (either 'user' or 'host'). - in: path - name: entityType - required: true - schema: - $ref: '#/components/schemas/Security_Entity_Analytics_API_EntityType' - responses: - '200': - content: - application/json; Elastic-Api-Version=2023-10-31: - schema: - type: object - properties: - indexPattern: - $ref: '#/components/schemas/Security_Entity_Analytics_API_IndexPattern' - indices: - items: - type: object - type: array - status: - $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineStatus' - transforms: - items: - type: object - type: array - type: - $ref: '#/components/schemas/Security_Entity_Analytics_API_EntityType' - description: Successful response - summary: Get Entity Engine stats - tags: - - Security Entity Analytics API - x-beta: true /api/entity_store/engines/{entityType}/stop: post: operationId: StopEntityEngine @@ -7749,6 +7713,12 @@ paths: /api/entity_store/status: get: operationId: GetEntityStoreStatus + parameters: + - description: If true returns a detailed status of the engine including all it's components + in: query + name: include_components + schema: + type: boolean responses: '200': content: @@ -7758,10 +7728,20 @@ paths: properties: engines: items: - $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineDescriptor' + allOf: + - $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineDescriptor' + - type: object + properties: + components: + items: + $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineComponentStatus' + type: array type: array status: $ref: '#/components/schemas/Security_Entity_Analytics_API_StoreStatus' + required: + - status + - engines description: Successful response summary: Get the status of the Entity Store tags: @@ -45755,6 +45735,47 @@ components: $ref: '#/components/schemas/Security_Entity_Analytics_API_AssetCriticalityLevel' required: - criticality_level + Security_Entity_Analytics_API_EngineComponentResource: + enum: + - entity_engine + - entity_definition + - index + - component_template + - index_template + - ingest_pipeline + - enrich_policy + - task + - transform + type: string + Security_Entity_Analytics_API_EngineComponentStatus: + type: object + properties: + errors: + items: + type: object + properties: + message: + type: string + title: + type: string + type: array + health: + enum: + - green + - yellow + - red + - unknown + type: string + id: + type: string + installed: + type: boolean + resource: + $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineComponentResource' + required: + - id + - installed + - resource Security_Entity_Analytics_API_EngineDataviewUpdateResult: type: object properties: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 208bced5d70f6..0b5b03378168e 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -10445,41 +10445,6 @@ paths: summary: Start an Entity Engine tags: - Security Entity Analytics API - /api/entity_store/engines/{entityType}/stats: - post: - operationId: GetEntityEngineStats - parameters: - - description: The entity type of the engine (either 'user' or 'host'). - in: path - name: entityType - required: true - schema: - $ref: '#/components/schemas/Security_Entity_Analytics_API_EntityType' - responses: - '200': - content: - application/json; Elastic-Api-Version=2023-10-31: - schema: - type: object - properties: - indexPattern: - $ref: '#/components/schemas/Security_Entity_Analytics_API_IndexPattern' - indices: - items: - type: object - type: array - status: - $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineStatus' - transforms: - items: - type: object - type: array - type: - $ref: '#/components/schemas/Security_Entity_Analytics_API_EntityType' - description: Successful response - summary: Get Entity Engine stats - tags: - - Security Entity Analytics API /api/entity_store/engines/{entityType}/stop: post: operationId: StopEntityEngine @@ -10630,6 +10595,12 @@ paths: /api/entity_store/status: get: operationId: GetEntityStoreStatus + parameters: + - description: If true returns a detailed status of the engine including all it's components + in: query + name: include_components + schema: + type: boolean responses: '200': content: @@ -10639,10 +10610,20 @@ paths: properties: engines: items: - $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineDescriptor' + allOf: + - $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineDescriptor' + - type: object + properties: + components: + items: + $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineComponentStatus' + type: array type: array status: $ref: '#/components/schemas/Security_Entity_Analytics_API_StoreStatus' + required: + - status + - engines description: Successful response summary: Get the status of the Entity Store tags: @@ -53478,6 +53459,47 @@ components: $ref: '#/components/schemas/Security_Entity_Analytics_API_AssetCriticalityLevel' required: - criticality_level + Security_Entity_Analytics_API_EngineComponentResource: + enum: + - entity_engine + - entity_definition + - index + - component_template + - index_template + - ingest_pipeline + - enrich_policy + - task + - transform + type: string + Security_Entity_Analytics_API_EngineComponentStatus: + type: object + properties: + errors: + items: + type: object + properties: + message: + type: string + title: + type: string + type: array + health: + enum: + - green + - yellow + - red + - unknown + type: string + id: + type: string + installed: + type: boolean + resource: + $ref: '#/components/schemas/Security_Entity_Analytics_API_EngineComponentResource' + required: + - id + - installed + - resource Security_Entity_Analytics_API_EngineDataviewUpdateResult: type: object properties: diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts index 7e419dbe6453c..25c47e838d85c 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.gen.ts @@ -39,6 +39,37 @@ export const EngineDescriptor = z.object({ error: z.object({}).optional(), }); +export type EngineComponentResource = z.infer; +export const EngineComponentResource = z.enum([ + 'entity_engine', + 'entity_definition', + 'index', + 'component_template', + 'index_template', + 'ingest_pipeline', + 'enrich_policy', + 'task', + 'transform', +]); +export type EngineComponentResourceEnum = typeof EngineComponentResource.enum; +export const EngineComponentResourceEnum = EngineComponentResource.enum; + +export type EngineComponentStatus = z.infer; +export const EngineComponentStatus = z.object({ + id: z.string(), + installed: z.boolean(), + resource: EngineComponentResource, + health: z.enum(['green', 'yellow', 'red', 'unknown']).optional(), + errors: z + .array( + z.object({ + title: z.string().optional(), + message: z.string().optional(), + }) + ) + .optional(), +}); + export type StoreStatus = z.infer; export const StoreStatus = z.enum(['not_installed', 'installing', 'running', 'stopped', 'error']); export type StoreStatusEnum = typeof StoreStatus.enum; diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml index 9a42191a556ac..5adb6fe038dc9 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/common.schema.yaml @@ -42,6 +42,49 @@ components: - updating - error + EngineComponentStatus: + type: object + required: + - id + - installed + - resource + properties: + id: + type: string + installed: + type: boolean + resource: + $ref: '#/components/schemas/EngineComponentResource' + health: + type: string + enum: + - green + - yellow + - red + - unknown + errors: + type: array + items: + type: object + properties: + title: + type: string + message: + type: string + + EngineComponentResource: + type: string + enum: + - entity_engine + - entity_definition + - index + - component_template + - index_template + - ingest_pipeline + - enrich_policy + - task + - transform + StoreStatus: type: string enum: diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enable.gen.ts similarity index 78% rename from x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.gen.ts rename to x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enable.gen.ts index 9644a1a333d16..70a58bf02be68 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.gen.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enable.gen.ts @@ -16,13 +16,7 @@ import { z } from '@kbn/zod'; -import { IndexPattern, EngineDescriptor, StoreStatus } from './common.gen'; - -export type GetEntityStoreStatusResponse = z.infer; -export const GetEntityStoreStatusResponse = z.object({ - status: StoreStatus.optional(), - engines: z.array(EngineDescriptor).optional(), -}); +import { IndexPattern, EngineDescriptor } from './common.gen'; export type InitEntityStoreRequestBody = z.infer; export const InitEntityStoreRequestBody = z.object({ diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enable.schema.yaml similarity index 65% rename from x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.schema.yaml rename to x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enable.schema.yaml index 306e876dfc4a7..81eec22d9ade9 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enablement.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/enable.schema.yaml @@ -41,24 +41,3 @@ paths: type: array items: $ref: './common.schema.yaml#/components/schemas/EngineDescriptor' - - /api/entity_store/status: - get: - x-labels: [ess, serverless] - x-codegen-enabled: true - operationId: GetEntityStoreStatus - summary: Get the status of the Entity Store - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - status: - $ref: './common.schema.yaml#/components/schemas/StoreStatus' - engines: - type: array - items: - $ref: './common.schema.yaml#/components/schemas/EngineDescriptor' diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/index.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/index.ts index b21308de36f18..32bc0a4efc07b 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/index.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/index.ts @@ -10,6 +10,5 @@ export * from './get.gen'; export * from './init.gen'; export * from './list.gen'; export * from './start.gen'; -export * from './stats.gen'; export * from './stop.gen'; export * from './apply_dataview_indices.gen'; diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.gen.ts deleted file mode 100644 index 8b2cb44947535..0000000000000 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.gen.ts +++ /dev/null @@ -1,39 +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. - */ - -/* - * NOTICE: Do not edit this file manually. - * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. - * - * info: - * title: Get Entity Engine stats - * version: 2023-10-31 - */ - -import { z } from '@kbn/zod'; - -import { EntityType, IndexPattern, EngineStatus } from '../common.gen'; - -export type GetEntityEngineStatsRequestParams = z.infer; -export const GetEntityEngineStatsRequestParams = z.object({ - /** - * The entity type of the engine (either 'user' or 'host'). - */ - entityType: EntityType, -}); -export type GetEntityEngineStatsRequestParamsInput = z.input< - typeof GetEntityEngineStatsRequestParams ->; - -export type GetEntityEngineStatsResponse = z.infer; -export const GetEntityEngineStatsResponse = z.object({ - type: EntityType.optional(), - indexPattern: IndexPattern.optional(), - status: EngineStatus.optional(), - transforms: z.array(z.object({})).optional(), - indices: z.array(z.object({})).optional(), -}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.schema.yaml deleted file mode 100644 index 25c010acc92ce..0000000000000 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/engine/stats.schema.yaml +++ /dev/null @@ -1,41 +0,0 @@ -openapi: 3.0.0 - -info: - title: Get Entity Engine stats - version: '2023-10-31' -paths: - /api/entity_store/engines/{entityType}/stats: - post: - x-labels: [ess, serverless] - x-codegen-enabled: true - operationId: GetEntityEngineStats - summary: Get Entity Engine stats - parameters: - - name: entityType - in: path - required: true - schema: - $ref: '../common.schema.yaml#/components/schemas/EntityType' - description: The entity type of the engine (either 'user' or 'host'). - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - type: - $ref : '../common.schema.yaml#/components/schemas/EntityType' - indexPattern: - $ref : '../common.schema.yaml#/components/schemas/IndexPattern' - status: - $ref : '../common.schema.yaml#/components/schemas/EngineStatus' - transforms: - type: array - items: - type: object - indices: - type: array - items: - type: object diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/status.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/status.gen.ts new file mode 100644 index 0000000000000..76249a787a43b --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/status.gen.ts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Enable Entity Store + * version: 2023-10-31 + */ + +import { z } from '@kbn/zod'; +import { BooleanFromString } from '@kbn/zod-helpers'; + +import { StoreStatus, EngineDescriptor, EngineComponentStatus } from './common.gen'; + +export type GetEntityStoreStatusRequestQuery = z.infer; +export const GetEntityStoreStatusRequestQuery = z.object({ + /** + * If true returns a detailed status of the engine including all it's components + */ + include_components: BooleanFromString.optional(), +}); +export type GetEntityStoreStatusRequestQueryInput = z.input< + typeof GetEntityStoreStatusRequestQuery +>; + +export type GetEntityStoreStatusResponse = z.infer; +export const GetEntityStoreStatusResponse = z.object({ + status: StoreStatus, + engines: z.array( + EngineDescriptor.merge( + z.object({ + components: z.array(EngineComponentStatus).optional(), + }) + ) + ), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/status.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/status.schema.yaml new file mode 100644 index 0000000000000..a1be66282328e --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/entity_store/status.schema.yaml @@ -0,0 +1,44 @@ +openapi: 3.0.0 + +info: + title: Enable Entity Store + version: '2023-10-31' +paths: + /api/entity_store/status: + get: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: GetEntityStoreStatus + summary: Get the status of the Entity Store + + parameters: + - name: include_components + in: query + schema: + type: boolean + description: If true returns a detailed status of the engine including all it's components + + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + required: + - status + - engines + properties: + status: + $ref: './common.schema.yaml#/components/schemas/StoreStatus' + engines: + type: array + items: + allOf: + - $ref: './common.schema.yaml#/components/schemas/EngineDescriptor' + - type: object + properties: + components: + type: array + items: + $ref: './common.schema.yaml#/components/schemas/EngineComponentStatus' diff --git a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts index 5a1cf49baecd1..016c77d7254dd 100644 --- a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -232,10 +232,9 @@ import type { UploadAssetCriticalityRecordsResponse, } from './entity_analytics/asset_criticality/upload_asset_criticality_csv.gen'; import type { - GetEntityStoreStatusResponse, InitEntityStoreRequestBodyInput, InitEntityStoreResponse, -} from './entity_analytics/entity_store/enablement.gen'; +} from './entity_analytics/entity_store/enable.gen'; import type { ApplyEntityEngineDataviewIndicesResponse } from './entity_analytics/entity_store/engine/apply_dataview_indices.gen'; import type { DeleteEntityEngineRequestQueryInput, @@ -257,10 +256,6 @@ import type { StartEntityEngineRequestParamsInput, StartEntityEngineResponse, } from './entity_analytics/entity_store/engine/start.gen'; -import type { - GetEntityEngineStatsRequestParamsInput, - GetEntityEngineStatsResponse, -} from './entity_analytics/entity_store/engine/stats.gen'; import type { StopEntityEngineRequestParamsInput, StopEntityEngineResponse, @@ -269,6 +264,10 @@ import type { ListEntitiesRequestQueryInput, ListEntitiesResponse, } from './entity_analytics/entity_store/entities/list_entities.gen'; +import type { + GetEntityStoreStatusRequestQueryInput, + GetEntityStoreStatusResponse, +} from './entity_analytics/entity_store/status.gen'; import type { CleanUpRiskEngineResponse } from './entity_analytics/risk_engine/engine_cleanup_route.gen'; import type { DisableRiskEngineResponse } from './entity_analytics/risk_engine/engine_disable_route.gen'; import type { EnableRiskEngineResponse } from './entity_analytics/risk_engine/engine_enable_route.gen'; @@ -1290,19 +1289,7 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } - async getEntityEngineStats(props: GetEntityEngineStatsProps) { - this.log.info(`${new Date().toISOString()} Calling API GetEntityEngineStats`); - return this.kbnClient - .request({ - path: replaceParams('/api/entity_store/engines/{entityType}/stats', props.params), - headers: { - [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', - }, - method: 'POST', - }) - .catch(catchAxiosErrorFormatAndThrow); - } - async getEntityStoreStatus() { + async getEntityStoreStatus(props: GetEntityStoreStatusProps) { this.log.info(`${new Date().toISOString()} Calling API GetEntityStoreStatus`); return this.kbnClient .request({ @@ -1311,6 +1298,8 @@ finalize it. [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31', }, method: 'GET', + + query: props.query, }) .catch(catchAxiosErrorFormatAndThrow); } @@ -2285,8 +2274,8 @@ export interface GetEndpointSuggestionsProps { export interface GetEntityEngineProps { params: GetEntityEngineRequestParamsInput; } -export interface GetEntityEngineStatsProps { - params: GetEntityEngineStatsRequestParamsInput; +export interface GetEntityStoreStatusProps { + query: GetEntityStoreStatusRequestQueryInput; } export interface GetNotesProps { query: GetNotesRequestQueryInput; diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml index fa79b170f3513..b1b85b8222786 100644 --- a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml @@ -430,41 +430,6 @@ paths: summary: Start an Entity Engine tags: - Security Entity Analytics API - /api/entity_store/engines/{entityType}/stats: - post: - operationId: GetEntityEngineStats - parameters: - - description: The entity type of the engine (either 'user' or 'host'). - in: path - name: entityType - required: true - schema: - $ref: '#/components/schemas/EntityType' - responses: - '200': - content: - application/json: - schema: - type: object - properties: - indexPattern: - $ref: '#/components/schemas/IndexPattern' - indices: - items: - type: object - type: array - status: - $ref: '#/components/schemas/EngineStatus' - transforms: - items: - type: object - type: array - type: - $ref: '#/components/schemas/EntityType' - description: Successful response - summary: Get Entity Engine stats - tags: - - Security Entity Analytics API /api/entity_store/engines/{entityType}/stop: post: operationId: StopEntityEngine @@ -615,6 +580,14 @@ paths: /api/entity_store/status: get: operationId: GetEntityStoreStatus + parameters: + - description: >- + If true returns a detailed status of the engine including all it's + components + in: query + name: include_components + schema: + type: boolean responses: '200': content: @@ -624,10 +597,20 @@ paths: properties: engines: items: - $ref: '#/components/schemas/EngineDescriptor' + allOf: + - $ref: '#/components/schemas/EngineDescriptor' + - type: object + properties: + components: + items: + $ref: '#/components/schemas/EngineComponentStatus' + type: array type: array status: $ref: '#/components/schemas/StoreStatus' + required: + - status + - engines description: Successful response summary: Get the status of the Entity Store tags: @@ -824,6 +807,47 @@ components: $ref: '#/components/schemas/AssetCriticalityLevel' required: - criticality_level + EngineComponentResource: + enum: + - entity_engine + - entity_definition + - index + - component_template + - index_template + - ingest_pipeline + - enrich_policy + - task + - transform + type: string + EngineComponentStatus: + type: object + properties: + errors: + items: + type: object + properties: + message: + type: string + title: + type: string + type: array + health: + enum: + - green + - yellow + - red + - unknown + type: string + id: + type: string + installed: + type: boolean + resource: + $ref: '#/components/schemas/EngineComponentResource' + required: + - id + - installed + - resource EngineDataviewUpdateResult: type: object properties: diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml index 9c2b3d62b1650..4a3b3495467e9 100644 --- a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_2023_10_31.bundled.schema.yaml @@ -430,41 +430,6 @@ paths: summary: Start an Entity Engine tags: - Security Entity Analytics API - /api/entity_store/engines/{entityType}/stats: - post: - operationId: GetEntityEngineStats - parameters: - - description: The entity type of the engine (either 'user' or 'host'). - in: path - name: entityType - required: true - schema: - $ref: '#/components/schemas/EntityType' - responses: - '200': - content: - application/json: - schema: - type: object - properties: - indexPattern: - $ref: '#/components/schemas/IndexPattern' - indices: - items: - type: object - type: array - status: - $ref: '#/components/schemas/EngineStatus' - transforms: - items: - type: object - type: array - type: - $ref: '#/components/schemas/EntityType' - description: Successful response - summary: Get Entity Engine stats - tags: - - Security Entity Analytics API /api/entity_store/engines/{entityType}/stop: post: operationId: StopEntityEngine @@ -615,6 +580,14 @@ paths: /api/entity_store/status: get: operationId: GetEntityStoreStatus + parameters: + - description: >- + If true returns a detailed status of the engine including all it's + components + in: query + name: include_components + schema: + type: boolean responses: '200': content: @@ -624,10 +597,20 @@ paths: properties: engines: items: - $ref: '#/components/schemas/EngineDescriptor' + allOf: + - $ref: '#/components/schemas/EngineDescriptor' + - type: object + properties: + components: + items: + $ref: '#/components/schemas/EngineComponentStatus' + type: array type: array status: $ref: '#/components/schemas/StoreStatus' + required: + - status + - engines description: Successful response summary: Get the status of the Entity Store tags: @@ -824,6 +807,47 @@ components: $ref: '#/components/schemas/AssetCriticalityLevel' required: - criticality_level + EngineComponentResource: + enum: + - entity_engine + - entity_definition + - index + - component_template + - index_template + - ingest_pipeline + - enrich_policy + - task + - transform + type: string + EngineComponentStatus: + type: object + properties: + errors: + items: + type: object + properties: + message: + type: string + title: + type: string + type: array + health: + enum: + - green + - yellow + - red + - unknown + type: string + id: + type: string + installed: + type: boolean + resource: + $ref: '#/components/schemas/EngineComponentResource' + required: + - id + - installed + - resource EngineDataviewUpdateResult: type: object properties: 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 f1afa13637bb8..0098b842748e2 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,15 +5,14 @@ * 2.0. */ import { useMemo } from 'react'; +import type { GetEntityStoreStatusResponse } from '../../../common/api/entity_analytics/entity_store/status.gen'; import type { - GetEntityStoreStatusResponse, InitEntityStoreRequestBody, InitEntityStoreResponse, -} from '../../../common/api/entity_analytics/entity_store/enablement.gen'; +} from '../../../common/api/entity_analytics/entity_store/enable.gen'; import type { DeleteEntityEngineResponse, EntityType, - GetEntityEngineResponse, InitEntityEngineResponse, ListEntityEnginesResponse, StopEntityEngineResponse, @@ -35,10 +34,11 @@ export const useEntityStoreRoutes = () => { }); }; - const getEntityStoreStatus = async () => { + const getEntityStoreStatus = async (withComponents = false) => { return http.fetch('/api/entity_store/status', { method: 'GET', version: API_VERSIONS.public.v1, + query: { include_components: withComponents }, }); }; @@ -58,13 +58,6 @@ export const useEntityStoreRoutes = () => { }); }; - const getEntityEngine = async (entityType: EntityType) => { - return http.fetch(`/api/entity_store/engines/${entityType}`, { - method: 'GET', - version: API_VERSIONS.public.v1, - }); - }; - const deleteEntityEngine = async (entityType: EntityType, deleteData: boolean) => { return http.fetch(`/api/entity_store/engines/${entityType}`, { method: 'DELETE', @@ -85,7 +78,6 @@ export const useEntityStoreRoutes = () => { getEntityStoreStatus, initEntityEngine, stopEntityEngine, - getEntityEngine, deleteEntityEngine, listEntityEngines, }; 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 index 8d0426fd99ceb..38449fa1e72ff 100644 --- 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 @@ -16,7 +16,7 @@ import { } 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 { GetEntityStoreStatusResponse } from '../../../../../common/api/entity_analytics/entity_store/status.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'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/components/engine_components_status.test.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/components/engine_components_status.test.tsx new file mode 100644 index 0000000000000..e97c9f961bb50 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/components/engine_components_status.test.tsx @@ -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 { EngineComponentsStatusTable } from './engine_components_status'; +import { + EngineComponentResourceEnum, + type EngineComponentStatus, +} from '../../../../../../../common/api/entity_analytics'; +import { TestProviders } from '../../../../../../common/mock'; + +const uninstalledWithErrorsComponent = { + id: 'entity_engine_id', + installed: false, + resource: EngineComponentResourceEnum.entity_engine, + errors: [{ title: 'Error 1', message: 'Error message 1' }], +}; + +const installedComponent = { + id: 'index_id', + resource: EngineComponentResourceEnum.index, + errors: [], + installed: true, +}; + +const mockComponents: EngineComponentStatus[] = [ + uninstalledWithErrorsComponent, + installedComponent, +]; + +const mockGetUrlForApp = jest.fn(); + +jest.mock('../../../../../../common/lib/kibana', () => { + return { + useKibana: jest.fn().mockReturnValue({ + services: { + application: { + getUrlForApp: () => mockGetUrlForApp(), + navigateToApp: jest.fn(), + }, + }, + }), + }; +}); + +describe('EngineComponentsStatusTable', () => { + it('renders the table with components', () => { + render(, { wrapper: TestProviders }); + expect(screen.getByTestId('engine-components-status-table')).toBeInTheDocument(); + }); + + it('expands and collapses rows correctly', () => { + render(, { wrapper: TestProviders }); + + const toggleButton = screen.getByLabelText('Expand'); + fireEvent.click(toggleButton); + + expect(screen.getByText('Error 1')).toBeInTheDocument(); + expect(screen.getByText('Error message 1')).toBeInTheDocument(); + + fireEvent.click(toggleButton); + + expect(screen.queryByText('Error 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Error message 1')).not.toBeInTheDocument(); + }); + + describe('columns', () => { + it('renders the correct resource text', () => { + render(, { + wrapper: TestProviders, + }); + expect(screen.getByText('Index')).toBeInTheDocument(); + }); + + it('renders checkmark on installation column when installed', () => { + render(, { + wrapper: TestProviders, + }); + + const icon = screen.getByTestId('installation-status'); + + expect(icon).toHaveAttribute('data-euiicon-type', 'check'); + }); + + it('renders cross on installation column when installed', () => { + render(, { + wrapper: TestProviders, + }); + + const icon = screen.getByTestId('installation-status'); + + expect(icon).toHaveAttribute('data-euiicon-type', 'cross'); + }); + + it('renders the correct health status', () => { + render(, { + wrapper: TestProviders, + }); + + expect(screen.queryByRole('img', { name: /health/i })).not.toBeInTheDocument(); + }); + + it('renders the correct identifier link', () => { + mockGetUrlForApp.mockReturnValue('mockedUrl'); + + render(, { + wrapper: TestProviders, + }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', 'mockedUrl'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/components/engine_components_status.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/components/engine_components_status.tsx new file mode 100644 index 0000000000000..eb789035d566f --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/components/engine_components_status.tsx @@ -0,0 +1,75 @@ +/* + * 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 { ReactNode } from 'react'; +import React, { useState, useMemo, useCallback, Fragment } from 'react'; +import { EuiSpacer, EuiHealth, EuiCodeBlock } from '@elastic/eui'; +import { BasicTable } from '../../../../../../common/components/ml/tables/basic_table'; +import { useColumns } from '../hooks/use_columns'; +import type { EngineComponentStatus } from '../../../../../../../common/api/entity_analytics'; + +type ExpandedRowMap = Record; + +const componentToId = ({ id, resource }: EngineComponentStatus) => `${resource}-${id}`; + +export const EngineComponentsStatusTable = ({ + components, +}: { + components: EngineComponentStatus[]; +}) => { + const [expandedItems, setExpandedItems] = useState([]); + + const itemIdToExpandedRowMap: ExpandedRowMap = useMemo(() => { + return expandedItems.reduce((acc, componentStatus) => { + if (componentStatus.errors && componentStatus.errors.length > 0) { + acc[componentToId(componentStatus)] = ( + + ); + } + return acc; + }, {}); + }, [expandedItems]); + + const onToggle = useCallback( + (component: EngineComponentStatus) => { + const isItemExpanded = expandedItems.includes(component); + + if (isItemExpanded) { + setExpandedItems(expandedItems.filter((item) => component !== item)); + } else { + setExpandedItems([...expandedItems, component]); + } + }, + [expandedItems] + ); + + const columns = useColumns(onToggle, expandedItems); + + return ( + + ); +}; + +const TransformExtendedData = ({ errors }: { errors: EngineComponentStatus['errors'] }) => { + return ( + <> + {errors?.map(({ title, message }) => ( + + + {title} + + {message} + + ))} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/hooks/use_columns.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/hooks/use_columns.tsx new file mode 100644 index 0000000000000..3156c768bf4fc --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/hooks/use_columns.tsx @@ -0,0 +1,184 @@ +/* + * 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 { IconColor } from '@elastic/eui'; +import { + EuiLink, + type EuiBasicTableColumn, + EuiHealth, + EuiScreenReaderOnly, + EuiButtonIcon, + EuiIcon, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EngineComponentResourceEnum, + type EngineComponentResource, +} from '../../../../../../../common/api/entity_analytics'; +import type { EngineComponentStatus } from '../../../../../../../common/api/entity_analytics'; +import { useKibana } from '../../../../../../common/lib/kibana'; + +type TableColumn = EuiBasicTableColumn; + +export const HEALTH_COLOR: Record['health'], IconColor> = { + green: 'success', + unknown: 'subdued', + yellow: 'warning', + red: 'danger', +} as const; + +const RESOURCE_TO_TEXT: Record = { + ingest_pipeline: 'Ingest Pipeline', + enrich_policy: 'Enrich Policy', + index: 'Index', + component_template: 'Component Template', + task: 'Task', + transform: 'Transform', + entity_definition: 'Entity Definition', + entity_engine: 'Engine', + index_template: 'Index Template', +}; + +export const useColumns = ( + onToggleExpandedItem: (item: EngineComponentStatus) => void, + expandedItems: EngineComponentStatus[] +): TableColumn[] => { + const { getUrlForApp } = useKibana().services.application; + + return useMemo( + () => [ + { + field: 'resource', + name: ( + + ), + width: '20%', + render: (resource: EngineComponentStatus['resource']) => RESOURCE_TO_TEXT[resource], + }, + { + field: 'id', + name: ( + + ), + render: (id: EngineComponentStatus['id'], { resource, installed }) => { + const path = getResourcePath(id, resource); + + if (!installed || !path) { + return id; + } + + return ( + + {id} + + ); + }, + }, + { + field: 'installed', + name: ( + + ), + width: '10%', + align: 'center', + render: (value: boolean) => + value ? ( + + ) : ( + + ), + }, + { + name: ( + + ), + width: '10%', + align: 'center', + render: ({ installed, resource, health }: EngineComponentStatus) => { + if (!installed) { + return null; + } + + return ; + }, + }, + { + isExpander: true, + align: 'right', + width: '40px', + name: ( + + + + + + ), + mobileOptions: { header: false }, + render: (component: EngineComponentStatus) => { + const isItemExpanded = expandedItems.includes(component); + + return component.errors && component.errors.length > 0 ? ( + onToggleExpandedItem(component)} + aria-label={isItemExpanded ? 'Collapse' : 'Expand'} + iconType={isItemExpanded ? 'arrowDown' : 'arrowRight'} + /> + ) : null; + }, + }, + ], + [expandedItems, getUrlForApp, onToggleExpandedItem] + ); +}; + +const getResourcePath = (id: string, resource: EngineComponentResource) => { + if (resource === EngineComponentResourceEnum.ingest_pipeline) { + return `ingest/ingest_pipelines?pipeline=${id}`; + } + + if (resource === EngineComponentResourceEnum.index_template) { + return `data/index_management/templates/${id}`; + } + + if (resource === EngineComponentResourceEnum.index) { + return `data/index_management/indices/index_details?indexName=${id}`; + } + + if (resource === EngineComponentResourceEnum.component_template) { + return `data/index_management/component_templates/${id}`; + } + + if (resource === EngineComponentResourceEnum.enrich_policy) { + return `data/index_management/enrich_policies?policy=${id}`; + } + + if (resource === EngineComponentResourceEnum.transform) { + return `data/transform/enrich_policies?_a=(transform:(queryText:'${id}'))`; + } + return null; +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/index.test.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/index.test.tsx new file mode 100644 index 0000000000000..2f9e74df39d39 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/index.test.tsx @@ -0,0 +1,100 @@ +/* + * 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 { EngineStatus } from '.'; + +import { TestProviders } from '@kbn/timelines-plugin/public/mock'; + +const mockUseEntityStore = jest.fn(); +jest.mock('../../hooks/use_entity_store', () => ({ + useEntityStoreStatus: () => mockUseEntityStore(), +})); + +const mockDownloadBlob = jest.fn(); +jest.mock('../../../../../common/utils/download_blob', () => ({ + downloadBlob: () => mockDownloadBlob(), +})); + +describe('EngineStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders loading spinner when data is loading', () => { + mockUseEntityStore.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + }); + + render(, { wrapper: TestProviders }); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders error state when there is an error', () => { + mockUseEntityStore.mockReturnValue({ + data: null, + isLoading: false, + error: new Error('Error'), + }); + + render(, { wrapper: TestProviders }); + + expect(screen.getByText('There was an error loading the engine status')).toBeInTheDocument(); + }); + + it('renders "No engines found" message when there are no engines', () => { + mockUseEntityStore.mockReturnValue({ + data: { engines: [] }, + isLoading: false, + error: null, + }); + + render(, { wrapper: TestProviders }); + + expect(screen.getByText('No engines found')).toBeInTheDocument(); + }); + + it('renders engine components when data is available', () => { + const mockData = { + engines: [ + { + type: 'test', + components: [{ id: 'entity_engine_id', installed: true, resource: 'entity_engine' }], + }, + ], + }; + mockUseEntityStore.mockReturnValue({ data: mockData, isLoading: false, error: null }); + + render(, { wrapper: TestProviders }); + + expect(screen.getByText('Test Store')).toBeInTheDocument(); + expect(screen.getByText('Download status')).toBeInTheDocument(); + }); + + it('calls downloadJson when download button is clicked', () => { + const mockData = { + engines: [ + { + type: 'test', + components: [{ id: 'entity_engine_id', installed: true, resource: 'entity_engine' }], + }, + ], + }; + mockUseEntityStore.mockReturnValue({ data: mockData, isLoading: false, error: null }); + + render(, { wrapper: TestProviders }); + + const downloadButton = screen.getByText('Download status'); + fireEvent.click(downloadButton); + + expect(mockDownloadBlob).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/index.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/index.tsx new file mode 100644 index 0000000000000..692841334bd50 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_store/components/engines_status/index.tsx @@ -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 React, { Fragment } from 'react'; +import { + EuiLoadingSpinner, + EuiPanel, + EuiSpacer, + EuiButtonEmpty, + EuiFlexItem, + EuiTitle, + EuiFlexGroup, + EuiCallOut, +} from '@elastic/eui'; +import { capitalize } from 'lodash/fp'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { useErrorToast } from '../../../../../common/hooks/use_error_toast'; +import { downloadBlob } from '../../../../../common/utils/download_blob'; +import { EngineComponentsStatusTable } from './components/engine_components_status'; +import { useEntityStoreStatus } from '../../hooks/use_entity_store'; + +const FILE_NAME = 'engines_status.json'; + +export const EngineStatus: React.FC = () => { + const { data, isLoading, error } = useEntityStoreStatus({ withComponents: true }); + + const downloadJson = () => { + downloadBlob(new Blob([JSON.stringify(data)]), FILE_NAME); + }; + + const errorMessage = i18n.translate( + 'xpack.securitySolution.entityAnalytics.entityStore.enginesStatus.queryError', + { + defaultMessage: 'There was an error loading the engine status', + } + ); + + useErrorToast(errorMessage, error); + + if (error) { + return ; + } + + if (!data || isLoading) return ; + + if (data.engines.length === 0) { + return ( + + ); + } + + return ( + + {data?.engines?.length > 0 && ( + + + + + + + + + + )} + + {(data?.engines ?? []).map((engine) => ( + + +

+ +

+
+ + + + + {engine.components && } + + + +
+ ))} +
+
+ ); +}; 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 b27b5b4cdf26a..ceb93d164af62 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,13 +5,12 @@ * 2.0. */ -import type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'; +import type { UseMutationOptions } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { - GetEntityStoreStatusResponse, - InitEntityStoreResponse, -} from '../../../../../common/api/entity_analytics/entity_store/enablement.gen'; +import type { IHttpFetchError } from '@kbn/core-http-browser'; +import type { GetEntityStoreStatusResponse } from '../../../../../common/api/entity_analytics/entity_store/status.gen'; +import type { InitEntityStoreResponse } from '../../../../../common/api/entity_analytics/entity_store/enable.gen'; import { useKibana } from '../../../../common/lib/kibana/kibana_react'; import type { DeleteEntityEngineResponse, @@ -26,19 +25,24 @@ const ENTITY_STORE_STATUS = ['GET', 'ENTITY_STORE_STATUS']; interface ResponseError { body: { message: string }; } -export const useEntityStoreStatus = (options: UseQueryOptions) => { + +interface Options { + withComponents?: boolean; +} + +export const useEntityStoreStatus = (opts: Options = {}) => { const { getEntityStoreStatus } = useEntityStoreRoutes(); - const query = useQuery(ENTITY_STORE_STATUS, getEntityStoreStatus, { + return useQuery({ + queryKey: [...ENTITY_STORE_STATUS, opts.withComponents], + queryFn: () => getEntityStoreStatus(opts.withComponents), refetchInterval: (data) => { if (data?.status === 'installing') { return 5000; } return false; }, - ...options, }); - return query; }; export const ENABLE_STORE_STATUS_KEY = ['POST', 'ENABLE_ENTITY_STORE']; @@ -102,15 +106,18 @@ export const useStopEntityEngineMutation = (options?: UseMutationOptions<{}>) => }; export const DELETE_ENTITY_ENGINE_STATUS_KEY = ['POST', 'STOP_ENTITY_ENGINE']; -export const useDeleteEntityEngineMutation = (options?: UseMutationOptions<{}>) => { +export const useDeleteEntityEngineMutation = ({ onSuccess }: { onSuccess?: () => void }) => { const queryClient = useQueryClient(); const { deleteEntityEngine } = useEntityStoreRoutes(); + return useMutation( () => Promise.all([deleteEntityEngine('user', true), deleteEntityEngine('host', true)]), { mutationKey: DELETE_ENTITY_ENGINE_STATUS_KEY, - onSuccess: () => queryClient.refetchQueries({ queryKey: ENTITY_STORE_STATUS }), - ...options, + onSuccess: () => { + queryClient.refetchQueries({ queryKey: ENTITY_STORE_STATUS }); + onSuccess?.(); + }, } ); }; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.test.tsx b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.test.tsx new file mode 100644 index 0000000000000..cc15a5360a3d1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.test.tsx @@ -0,0 +1,207 @@ +/* + * 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 { EntityStoreManagementPage } from './entity_store_management_page'; +import { TestProviders } from '../../common/mock'; + +jest.mock('../components/entity_store/components/engines_status', () => ({ + EngineStatus: () => {'Mocked Engine Status Tab'}, +})); + +const mockUseAssetCriticalityPrivileges = jest.fn(); +jest.mock('../components/asset_criticality/use_asset_criticality', () => ({ + useAssetCriticalityPrivileges: () => mockUseAssetCriticalityPrivileges(), +})); + +const mockUseIsExperimentalFeatureEnabled = jest.fn(); +jest.mock('../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: () => mockUseIsExperimentalFeatureEnabled(), +})); + +const mockUseHasSecurityCapability = jest.fn().mockReturnValue(true); +jest.mock('../../helper_hooks', () => ({ + useHasSecurityCapability: () => mockUseHasSecurityCapability(), +})); + +const mockUseEntityStoreStatus = jest.fn(); +jest.mock('../components/entity_store/hooks/use_entity_store', () => ({ + ...jest.requireActual('../components/entity_store/hooks/use_entity_store'), + useEntityStoreStatus: () => mockUseEntityStoreStatus(), +})); + +const mockUseEntityEnginePrivileges = jest.fn(); +jest.mock('../components/entity_store/hooks/use_entity_engine_privileges', () => ({ + useEntityEnginePrivileges: () => mockUseEntityEnginePrivileges(), +})); + +describe('EntityStoreManagementPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseAssetCriticalityPrivileges.mockReturnValue({ + isLoading: false, + data: { has_write_permissions: true }, + }); + + mockUseEntityEnginePrivileges.mockReturnValue({ + data: { has_all_required: true }, + }); + + mockUseEntityStoreStatus.mockReturnValue({ + data: { + status: 'running', + }, + errors: [], + }); + + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + }); + + it('does not render page when asset criticality is loading', () => { + mockUseAssetCriticalityPrivileges.mockReturnValue({ + isLoading: true, + }); + + render(, { wrapper: TestProviders }); + + expect(screen.queryByTestId('entityStoreManagementPage')).not.toBeInTheDocument(); + }); + + it('renders the page header', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + render(, { wrapper: TestProviders }); + + expect(screen.getByTestId('entityStoreManagementPage')).toBeInTheDocument(); + expect(screen.getByText('Entity Store')).toBeInTheDocument(); + }); + + it('disables the switch when status is installing', () => { + mockUseEntityStoreStatus.mockReturnValue({ + data: { + status: 'installing', + }, + errors: [], + }); + + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + render(, { wrapper: TestProviders }); + + expect(screen.getByTestId('entity-store-switch')).toBeDisabled(); + }); + + it('show clear entity data modal when clear data button clicked', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + render(, { wrapper: TestProviders }); + + fireEvent.click(screen.getByText('Clear Entity Data')); + + expect(screen.getByText('Clear Entity data?')).toBeInTheDocument(); + }); + + it('renders the AssetCriticalityIssueCallout when there is an error', () => { + mockUseAssetCriticalityPrivileges.mockReturnValue({ + isLoading: false, + error: { body: { message: 'Error message', status_code: 403 } }, + }); + + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + render(, { wrapper: TestProviders }); + + expect( + screen.getByText('Asset criticality CSV file upload functionality unavailable.') + ).toBeInTheDocument(); + expect(screen.getByText('Error message')).toBeInTheDocument(); + }); + + it('renders the InsufficientAssetCriticalityPrivilegesCallout when there are no write permissions', () => { + mockUseAssetCriticalityPrivileges.mockReturnValue({ + isLoading: false, + data: { has_write_permissions: false }, + }); + + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + render(, { wrapper: TestProviders }); + + expect( + screen.getByText('Insufficient index privileges to perform CSV upload') + ).toBeInTheDocument(); + }); + + it('selects the Import tab by default', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + render(, { wrapper: TestProviders }); + + expect(screen.getByText('Import Entities').parentNode).toHaveAttribute('aria-selected', 'true'); + }); + + it('switches to the Status tab when clicked', () => { + mockUseEntityStoreStatus.mockReturnValue({ + data: { + status: 'running', + }, + errors: [], + }); + + mockUseEntityEnginePrivileges.mockReturnValue({ + data: { has_all_required: true }, + }); + + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + render(, { wrapper: TestProviders }); + + fireEvent.click(screen.getByText('Engine Status')); + + expect(screen.getByText('Engine Status').parentNode).toHaveAttribute('aria-selected', 'true'); + }); + + it('does not render the Status tab when entity store is not installed', () => { + mockUseEntityStoreStatus.mockReturnValue({ + data: { + status: 'not_installed', + }, + errors: [], + }); + + mockUseEntityEnginePrivileges.mockReturnValue({ + data: { has_all_required: true }, + }); + + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + render(, { wrapper: TestProviders }); + + expect(screen.queryByText('Engine Status')).not.toBeInTheDocument(); + }); + + it('does not render the Status tab when privileges are missing', () => { + mockUseEntityStoreStatus.mockReturnValue({ + data: { + status: 'running', + }, + errors: [], + }); + + mockUseEntityEnginePrivileges.mockReturnValue({ + data: { has_all_required: false, privileges: { kibana: {}, elasticsearch: {} } }, + }); + + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + render(, { wrapper: TestProviders }); + + expect(screen.queryByText('Engine Status')).not.toBeInTheDocument(); + }); +}); 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 7c00f61f62fbb..c0b9d8d697e53 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 @@ -21,13 +21,15 @@ import { EuiCode, EuiSwitch, EuiHealth, - EuiButton, EuiLoadingSpinner, EuiToolTip, EuiBetaBadge, + EuiTabs, + EuiTab, + EuiButtonEmpty, } from '@elastic/eui'; import type { ReactNode } from 'react'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { SecurityAppError } from '@kbn/securitysolution-t-grid'; @@ -47,11 +49,18 @@ import { 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'; +import { EngineStatus } from '../components/entity_store/components/engines_status'; + +enum TabId { + Import = 'import', + Status = 'status', +} const isSwitchDisabled = (status?: StoreStatus) => status === 'error' || status === 'installing'; const isEntityStoreEnabled = (status?: StoreStatus) => status === 'running'; const canDeleteEntityEngine = (status?: StoreStatus) => !['not_installed', 'installing'].includes(status || ''); +const isEntityStoreInstalled = (status?: StoreStatus) => status && status !== 'not_installed'; export const EntityStoreManagementPage = () => { const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics'); @@ -62,7 +71,7 @@ export const EntityStoreManagementPage = () => { isLoading: assetCriticalityIsLoading, } = useAssetCriticalityPrivileges('AssetCriticalityUploadPage'); const hasAssetCriticalityWritePermissions = assetCriticalityPrivileges?.has_write_permissions; - + const [selectedTabId, setSelectedTabId] = useState(TabId.Import); const entityStoreStatus = useEntityStoreStatus({}); const enableStoreMutation = useEnableEntityStoreMutation(); @@ -91,6 +100,15 @@ export const EntityStoreManagementPage = () => { const { data: privileges } = useEntityEnginePrivileges(); + const shouldDisplayEngineStatusTab = + isEntityStoreInstalled(entityStoreStatus.data?.status) && privileges?.has_all_required; + + useEffect(() => { + if (selectedTabId === TabId.Status && !shouldDisplayEngineStatusTab) { + setSelectedTabId(TabId.Import); + } + }, [shouldDisplayEngineStatusTab, selectedTabId]); + if (assetCriticalityIsLoading) { // Wait for permission before rendering content to avoid flickering return null; @@ -149,8 +167,18 @@ export const EntityStoreManagementPage = () => { onSwitch={onSwitchClick} status={entityStoreStatus.data?.status} />, + canDeleteEntityEngine(entityStoreStatus.data?.status) ? ( + + ) : null, ] - : [] + : undefined } /> @@ -169,14 +197,44 @@ export const EntityStoreManagementPage = () => { )} - - + + + + setSelectedTabId(TabId.Import)} + > + + + + {shouldDisplayEngineStatusTab && ( + setSelectedTabId(TabId.Status)} + > + + + )} + + + - + {selectedTabId === TabId.Import && ( + + )} + {selectedTabId === TabId.Status && } {enableStoreMutation.isError && ( @@ -210,19 +268,7 @@ export const EntityStoreManagementPage = () => {
)} {callouts} - - {!isEntityStoreFeatureFlagDisabled && - privileges?.has_all_required && - canDeleteEntityEngine(entityStoreStatus.data?.status) && ( - - )} + {selectedTabId === TabId.Import && }
@@ -375,10 +421,8 @@ const InsufficientAssetCriticalityPrivilegesCallout: React.FC = () => { ); }; -const AssetCriticalityIssueCallout: React.FC = ({ +const AssetCriticalityIssueCallout: React.FC<{ errorMessage?: string | ReactNode }> = ({ errorMessage, -}: { - errorMessage?: string | ReactNode; }) => { const msg = errorMessage ?? ( ; isClearModalVisible: boolean; closeClearModal: () => void; @@ -413,37 +457,19 @@ const ClearEntityDataPanel: React.FC<{ }> = ({ deleteEntityEngineMutation, isClearModalVisible, closeClearModal, showClearModal }) => { return ( <> - - -

- -

- - -
- - { - showClearModal(); - }} - > - - -
+ { + showClearModal(); + }} + > + + + {isClearModalVisible && ( { if (!hasEntityAnalyticsCapability || assetCriticalityPrivilegesError?.body.status_code === 403) { - return ; + return ( + + ); } if (!hasAssetCriticalityWritePermissions) { return ; } return ( - -

- -

-
`${definitionId}-latest@platform`; @@ -41,3 +45,24 @@ export const deleteEntityIndexComponentTemplate = ({ unitedDefinition, esClient } ); }; + +export const getEntityIndexComponentTemplateStatus = async ({ + definitionId, + esClient, +}: Pick & { definitionId: string }): Promise => { + const name = getComponentTemplateName(definitionId); + const componentTemplate = await esClient.cluster.getComponentTemplate( + { + name, + }, + { + ignore: [404], + } + ); + + return { + id: name, + installed: componentTemplate?.component_templates?.length > 0, + resource: EngineComponentResourceEnum.component_template, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/enrich_policy.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/enrich_policy.ts index 4b8ce594a6cb7..7064a4dd98851 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/enrich_policy.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/enrich_policy.ts @@ -7,6 +7,7 @@ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import type { EnrichPutPolicyRequest } from '@elastic/elasticsearch/lib/api/types'; +import { EngineComponentResourceEnum } from '../../../../../common/api/entity_analytics'; import { getEntitiesIndexName } from '../utils'; import type { UnitedEntityDefinition } from '../united_entity_definitions'; @@ -105,3 +106,21 @@ export const deleteFieldRetentionEnrichPolicy = async ({ } } }; + +export const getFieldRetentionEnrichPolicyStatus = async ({ + definitionMetadata, + esClient, +}: { + definitionMetadata: DefinitionMetadata; + esClient: ElasticsearchClient; +}) => { + const name = getFieldRetentionEnrichPolicyName(definitionMetadata); + const policy = await esClient.enrich.getPolicy({ name }, { ignore: [404] }); + const policies = policy.policies; + + return { + installed: policies.length > 0, + id: name, + resource: EngineComponentResourceEnum.enrich_policy, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/entity_index.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/entity_index.ts index 6fb5935618dfc..0de8fe20050ce 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/entity_index.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/entity_index.ts @@ -6,7 +6,11 @@ */ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; -import type { EntityType } from '../../../../../common/api/entity_analytics'; +import { + EngineComponentResourceEnum, + type EngineComponentStatus, + type EntityType, +} from '../../../../../common/api/entity_analytics'; import { getEntitiesIndexName } from '../utils'; import { createOrUpdateIndex } from '../../utils/create_or_update_index'; @@ -36,3 +40,21 @@ export const deleteEntityIndex = ({ entityType, esClient, namespace }: Options) ignore: [404], } ); + +export const getEntityIndexStatus = async ({ + entityType, + esClient, + namespace, +}: Pick): Promise => { + const index = getEntitiesIndexName(entityType, namespace); + const exists = await esClient.indices.exists( + { + index, + }, + { + ignore: [404], + } + ); + + return { id: index, installed: exists, resource: EngineComponentResourceEnum.index }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/ingest_pipeline.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/ingest_pipeline.ts index f4d2b848b726f..f57102fd13f14 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/ingest_pipeline.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/elasticsearch_assets/ingest_pipeline.ts @@ -8,6 +8,7 @@ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import type { IngestProcessorContainer } from '@elastic/elasticsearch/lib/api/types'; import type { EntityDefinition } from '@kbn/entities-schema'; +import { EngineComponentResourceEnum } from '../../../../../common/api/entity_analytics'; import { type FieldRetentionDefinition } from '../field_retention_definition'; import { debugDeepCopyContextStep, @@ -161,3 +162,27 @@ export const deletePlatformPipeline = ({ } ); }; + +export const getPlatformPipelineStatus = async ({ + definition, + esClient, +}: { + definition: EntityDefinition; + esClient: ElasticsearchClient; +}) => { + const pipelineId = getPlatformPipelineId(definition); + const pipeline = await esClient.ingest.getPipeline( + { + id: pipelineId, + }, + { + ignore: [404], + } + ); + + return { + id: pipelineId, + installed: !!pipeline[pipelineId], + resource: EngineComponentResourceEnum.ingest_pipeline, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts index a89469acf569b..72d650846fc55 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/entity_store_data_client.ts @@ -20,19 +20,28 @@ import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import type { DataViewsService } from '@kbn/data-views-plugin/common'; import { isEqual } from 'lodash/fp'; import moment from 'moment'; +import type { EntityDefinitionWithState } from '@kbn/entityManager-plugin/server/lib/entities/types'; +import type { EntityDefinition } from '@kbn/entities-schema'; +import type { estypes } from '@elastic/elasticsearch'; import type { + GetEntityStoreStatusRequestQuery, GetEntityStoreStatusResponse, +} from '../../../../common/api/entity_analytics/entity_store/status.gen'; +import type { InitEntityStoreRequestBody, InitEntityStoreResponse, -} from '../../../../common/api/entity_analytics/entity_store/enablement.gen'; +} from '../../../../common/api/entity_analytics/entity_store/enable.gen'; import type { AppClient } from '../../..'; -import { EntityType } from '../../../../common/api/entity_analytics'; +import { EngineComponentResourceEnum, EntityType } from '../../../../common/api/entity_analytics'; import type { Entity, EngineDataviewUpdateResult, InitEntityEngineRequestBody, InitEntityEngineResponse, InspectQuery, + ListEntityEnginesResponse, + EngineComponentStatus, + EngineComponentResource, } from '../../../../common/api/entity_analytics'; import { EngineDescriptorClient } from './saved_object/engine_descriptor'; import { ENGINE_STATUS, ENTITY_STORE_STATUS, MAX_SEARCH_RESPONSE_SIZE } from './constants'; @@ -41,6 +50,7 @@ import { getUnitedEntityDefinition } from './united_entity_definitions'; import { startEntityStoreFieldRetentionEnrichTask, removeEntityStoreFieldRetentionEnrichTask, + getEntityStoreFieldRetentionEnrichTaskState as getEntityStoreFieldRetentionEnrichTaskStatus, } from './task'; import { createEntityIndex, @@ -52,6 +62,10 @@ import { createFieldRetentionEnrichPolicy, executeFieldRetentionEnrichPolicy, deleteFieldRetentionEnrichPolicy, + getPlatformPipelineStatus, + getFieldRetentionEnrichPolicyStatus, + getEntityIndexStatus, + getEntityIndexComponentTemplateStatus, } from './elasticsearch_assets'; import { RiskScoreDataClient } from '../risk_score/risk_score_data_client'; import { @@ -62,7 +76,6 @@ import { isPromiseRejected, } from './utils'; import { EntityEngineActions } from './auditing/actions'; -import { EntityStoreResource } from './auditing/resources'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../audit'; import type { EntityRecord, EntityStoreConfig } from './types'; import { @@ -71,6 +84,19 @@ import { } from '../../telemetry/event_based/events'; import { CRITICALITY_VALUES } from '../asset_criticality/constants'; +// Workaround. TransformState type is wrong. The health type should be: TransformHealth from '@kbn/transform-plugin/common/types/transform_stats' +export interface TransformHealth extends estypes.TransformGetTransformStatsTransformStatsHealth { + issues?: TransformHealthIssue[]; +} + +export interface TransformHealthIssue { + type: string; + issue: string; + details?: string; + count: number; + first_occurrence?: number; +} + interface EntityStoreClientOpts { logger: Logger; clusterClient: IScopedClusterClient; @@ -131,6 +157,42 @@ export class EntityStoreDataClient { }); } + private async getEngineComponentsState( + type: EntityType, + definition?: EntityDefinition + ): Promise { + const { namespace, taskManager } = this.options; + + return definition + ? Promise.all([ + ...(taskManager + ? [getEntityStoreFieldRetentionEnrichTaskStatus({ namespace, taskManager })] + : []), + getPlatformPipelineStatus({ + definition, + esClient: this.esClient, + }), + getFieldRetentionEnrichPolicyStatus({ + definitionMetadata: { + namespace, + entityType: type, + version: definition.version, + }, + esClient: this.esClient, + }), + getEntityIndexStatus({ + entityType: type, + esClient: this.esClient, + namespace, + }), + getEntityIndexComponentTemplateStatus({ + definitionId: definition.id, + esClient: this.esClient, + }), + ]) + : Promise.resolve([] as EngineComponentStatus[]); + } + public async enable( { indexPattern = '', filter = '', fieldHistoryLength = 10 }: InitEntityStoreRequestBody, { pipelineDebugMode = false }: { pipelineDebugMode?: boolean } = {} @@ -152,7 +214,10 @@ export class EntityStoreDataClient { return { engines, succeeded: true }; } - public async status(): Promise { + public async status({ + include_components: withComponents = false, + }: GetEntityStoreStatusRequestQuery): Promise { + const { namespace } = this.options; const { engines, count } = await this.engineClient.list(); let status = ENTITY_STORE_STATUS.RUNNING; @@ -166,7 +231,38 @@ export class EntityStoreDataClient { status = ENTITY_STORE_STATUS.INSTALLING; } - return { engines, status }; + if (withComponents) { + const enginesWithComponents = await Promise.all( + engines.map(async (engine) => { + const entityDefinitionId = buildEntityDefinitionId(engine.type, namespace); + const { + definitions: [definition], + } = await this.entityClient.getEntityDefinitions({ + id: entityDefinitionId, + includeState: withComponents, + }); + + const definitionComponents = this.getComponentFromEntityDefinition( + entityDefinitionId, + definition + ); + + const entityStoreComponents = await this.getEngineComponentsState( + engine.type, + definition + ); + + return { + ...engine, + components: [...definitionComponents, ...entityStoreComponents], + }; + }) + ); + + return { engines: enginesWithComponents, status }; + } else { + return { engines, status }; + } } public async init( @@ -203,7 +299,7 @@ export class EntityStoreDataClient { this.log('info', entityType, `Initializing entity store`); this.audit( EntityEngineActions.INIT, - EntityStoreResource.ENTITY_ENGINE, + EngineComponentResourceEnum.entity_engine, entityType, 'Initializing entity engine' ); @@ -332,7 +428,7 @@ export class EntityStoreDataClient { this.audit( EntityEngineActions.INIT, - EntityStoreResource.ENTITY_ENGINE, + EngineComponentResourceEnum.entity_engine, entityType, 'Failed to initialize entity engine resources', err @@ -355,6 +451,50 @@ export class EntityStoreDataClient { } } + public getComponentFromEntityDefinition( + id: string, + definition: EntityDefinitionWithState | EntityDefinition + ): EngineComponentStatus[] { + if (!definition) { + return [ + { + id, + installed: false, + resource: EngineComponentResourceEnum.entity_definition, + }, + ]; + } + + if ('state' in definition) { + return [ + { + id: definition.id, + installed: definition.state.installed, + resource: EngineComponentResourceEnum.entity_definition, + }, + ...definition.state.components.transforms.map(({ installed, running, stats }) => ({ + id, + resource: EngineComponentResourceEnum.transform, + installed, + errors: (stats?.health as TransformHealth)?.issues?.map(({ issue, details }) => ({ + title: issue, + message: details, + })), + })), + ...definition.state.components.ingestPipelines.map((pipeline) => ({ + resource: EngineComponentResourceEnum.ingest_pipeline, + ...pipeline, + })), + ...definition.state.components.indexTemplates.map(({ installed }) => ({ + id, + installed, + resource: EngineComponentResourceEnum.index_template, + })), + ]; + } + return []; + } + public async getExistingEntityDefinition(entityType: EntityType) { const entityDefinitionId = buildEntityDefinitionId(entityType, this.options.namespace); @@ -387,7 +527,7 @@ export class EntityStoreDataClient { const fullEntityDefinition = await this.getExistingEntityDefinition(entityType); this.audit( EntityEngineActions.START, - EntityStoreResource.ENTITY_DEFINITION, + EngineComponentResourceEnum.entity_definition, entityType, 'Starting entity definition' ); @@ -414,7 +554,7 @@ export class EntityStoreDataClient { const fullEntityDefinition = await this.getExistingEntityDefinition(entityType); this.audit( EntityEngineActions.STOP, - EntityStoreResource.ENTITY_DEFINITION, + EngineComponentResourceEnum.entity_definition, entityType, 'Stopping entity definition' ); @@ -428,7 +568,7 @@ export class EntityStoreDataClient { return this.engineClient.get(entityType); } - public async list() { + public async list(): Promise { return this.engineClient.list(); } @@ -457,7 +597,7 @@ export class EntityStoreDataClient { this.log('info', entityType, `Deleting entity store`); this.audit( EntityEngineActions.DELETE, - EntityStoreResource.ENTITY_ENGINE, + EngineComponentResourceEnum.entity_engine, entityType, 'Deleting entity engine' ); @@ -525,7 +665,7 @@ export class EntityStoreDataClient { this.audit( EntityEngineActions.DELETE, - EntityStoreResource.ENTITY_ENGINE, + EngineComponentResourceEnum.entity_engine, entityType, 'Failed to delete entity engine', err @@ -672,7 +812,7 @@ export class EntityStoreDataClient { private audit( action: EntityEngineActions, - resource: EntityStoreResource, + resource: EngineComponentResource, entityType: EntityType, msg: string, error?: Error diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/enablement.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/enablement.ts index 16813fccdf235..d19e9699f4756 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/enablement.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/enablement.ts @@ -10,8 +10,8 @@ import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; import { transformError } from '@kbn/securitysolution-es-utils'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; -import type { InitEntityStoreResponse } from '../../../../../common/api/entity_analytics/entity_store/enablement.gen'; -import { InitEntityStoreRequestBody } from '../../../../../common/api/entity_analytics/entity_store/enablement.gen'; +import type { InitEntityStoreResponse } from '../../../../../common/api/entity_analytics/entity_store/enable.gen'; +import { InitEntityStoreRequestBody } from '../../../../../common/api/entity_analytics/entity_store/enable.gen'; import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { checkAndInitAssetCriticalityResources } from '../../asset_criticality/check_and_init_asset_criticality_resources'; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts deleted file mode 100644 index 24785fbd5c015..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/stats.ts +++ /dev/null @@ -1,64 +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 type { IKibanaResponse, Logger } from '@kbn/core/server'; -import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; -import { transformError } from '@kbn/securitysolution-es-utils'; -import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; - -import type { GetEntityEngineStatsResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/stats.gen'; -import { GetEntityEngineStatsRequestParams } from '../../../../../common/api/entity_analytics/entity_store/engine/stats.gen'; -import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; -import type { EntityAnalyticsRoutesDeps } from '../../types'; - -export const getEntityEngineStatsRoute = ( - router: EntityAnalyticsRoutesDeps['router'], - logger: Logger -) => { - router.versioned - .post({ - access: 'public', - path: '/api/entity_store/engines/{entityType}/stats', - security: { - authz: { - requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], - }, - }, - }) - .addVersion( - { - version: API_VERSIONS.public.v1, - validate: { - request: { - params: buildRouteValidationWithZod(GetEntityEngineStatsRequestParams), - }, - }, - }, - - async ( - context, - request, - response - ): Promise> => { - const siemResponse = buildSiemResponse(response); - - try { - // TODO - throw new Error('Not implemented'); - - // return response.ok({ body }); - } catch (e) { - logger.error('Error in GetEntityEngineStats:', e); - const error = transformError(e); - return siemResponse.error({ - statusCode: error.statusCode, - body: error.message, - }); - } - } - ); -}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/status.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/status.ts index 7a59b59b9914a..c2c24b114f66b 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/status.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/status.ts @@ -15,7 +15,9 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; import { transformError } from '@kbn/securitysolution-es-utils'; -import type { GetEntityStoreStatusResponse } from '../../../../../common/api/entity_analytics/entity_store/enablement.gen'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import type { GetEntityStoreStatusResponse } from '../../../../../common/api/entity_analytics/entity_store/status.gen'; +import { GetEntityStoreStatusRequestQuery } from '../../../../../common/api/entity_analytics/entity_store/status.gen'; import { API_VERSIONS, APP_ID } from '../../../../../common/constants'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { checkAndInitAssetCriticalityResources } from '../../asset_criticality/check_and_init_asset_criticality_resources'; @@ -38,7 +40,11 @@ export const getEntityStoreStatusRoute = ( .addVersion( { version: API_VERSIONS.public.v1, - validate: {}, + validate: { + request: { + query: buildRouteValidationWithZod(GetEntityStoreStatusRequestQuery), + }, + }, }, async ( @@ -54,7 +60,7 @@ export const getEntityStoreStatusRoute = ( try { const body: GetEntityStoreStatusResponse = await secSol .getEntityStoreDataClient() - .status(); + .status(request.query); return response.ok({ body }); } catch (e) { diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/task/field_retention_enrichment_task.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/task/field_retention_enrichment_task.ts index 708b74277faae..3d103ea2af467 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/task/field_retention_enrichment_task.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/entity_store/task/field_retention_enrichment_task.ts @@ -13,7 +13,10 @@ import type { TaskManagerSetupContract, TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; -import type { EntityType } from '../../../../../common/api/entity_analytics/entity_store'; +import { + EngineComponentResourceEnum, + type EntityType, +} from '../../../../../common/api/entity_analytics/entity_store'; import { defaultState, stateSchemaByVersion, @@ -275,3 +278,37 @@ const createTaskRunnerFactory = }, }; }; + +export const getEntityStoreFieldRetentionEnrichTaskState = async ({ + namespace, + taskManager, +}: { + namespace: string; + taskManager: TaskManagerStartContract; +}) => { + const taskId = getTaskId(namespace); + try { + const taskState = await taskManager.get(taskId); + + return { + id: taskState.id, + resource: EngineComponentResourceEnum.task, + installed: true, + enabled: taskState.enabled, + status: taskState.status, + retryAttempts: taskState.attempts, + nextRun: taskState.runAt, + lastRun: taskState.state.lastExecutionTimestamp, + runs: taskState.state.runs, + }; + } catch (e) { + if (SavedObjectsErrorHelpers.isNotFoundError(e)) { + return { + id: taskId, + installed: false, + resource: EngineComponentResourceEnum.task, + }; + } + throw e; + } +}; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 97415cc236720..45282c5713e11 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -40397,7 +40397,6 @@ "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.noPermissionTitle": "Privilèges d'index insuffisants pour effectuer le chargement de fichiers CSV", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.resultsStepTitle": "Résultats", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.selectFileStepTitle": "Sélectionner un fichier", - "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.subTitle": "Importer des entités à l'aide d'un fichier texte", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.unavailable": "La fonctionnalité de chargement de fichiers CSV de criticité des ressources n'est pas disponible.", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.unsupportedFileTypeError": "Format de fichier sélectionné non valide. Veuillez choisir un fichier {supportedFileExtensions} et réessayer", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.uploadFileSizeLimit": "La taille maximale de fichier est de : {maxFileSize}", @@ -40439,7 +40438,6 @@ "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntitiesModal.clearAllEntities": "Effacer toutes les entités", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntitiesModal.close": "Fermer", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntitiesModal.title": "Effacer les données des entités ?", - "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntityData": "Supprimez toutes les données d'entité extraites du stockage. Cette action supprimera définitivement les enregistrements d’utilisateur et d'hôte persistants, les données ne seront par ailleurs plus disponibles pour l'analyse. Procédez avec prudence, car cette opération est irréversible. Notez que cette opération ne supprimera pas les données sources, les scores de risque des entités ni les attributions de criticité des ressources.", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.featureFlagDisabled": "Les fonctionnalités du stockage d'entités ne sont pas disponibles", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.subTitle": "Permet un monitoring complet des hôtes et des utilisateurs de votre système.", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.title": "Stockage d'entités", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6fb9f64a18d05..9aa8aa0977b71 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -40366,7 +40366,6 @@ "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.noPermissionTitle": "CSVアップロードを実行する十分なインデックス権限がありません", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.resultsStepTitle": "結果", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.selectFileStepTitle": "ファイルを選択", - "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.subTitle": "テキストファイルを使用してエンティティをインポート", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.unavailable": "アセット重要度CSVファイルアップロード機能が利用できません。", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.unsupportedFileTypeError": "無効なファイル形式が選択されました。{supportedFileExtensions}ファイルを選択して再試行してください。", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.uploadFileSizeLimit": "最大ファイルサイズ:{maxFileSize}", @@ -40408,7 +40407,6 @@ "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntitiesModal.clearAllEntities": "すべてのエンティティを消去", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntitiesModal.close": "閉じる", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntitiesModal.title": "エンティティデータを消去しますか?", - "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntityData": "すべての抽出されたエンティティデータをストアから削除します。このアクションにより、永続化されたユーザーおよびホストレコードが完全に削除され、データは分析で使用できなくなります。注意して続行してください。この操作は元に戻せません。この処理では、ソースデータ、エンティティリスクスコア、アセット重要度割り当ては削除されません。", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.featureFlagDisabled": "エンティティストア機能を利用できません", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.subTitle": "システムのホストとユーザーを包括的に監視できます。", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.title": "エンティティストア", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9d4eb17c385d7..389ccb9e5999f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -39738,7 +39738,6 @@ "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.noPermissionTitle": "索引权限不足,无法执行 CSV 上传", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.resultsStepTitle": "结果", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.selectFileStepTitle": "选择文件", - "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.subTitle": "使用文本文件导入实体", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.unavailable": "资产关键度 CSV 文件上传功能不可用。", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.unsupportedFileTypeError": "选定的文件格式无效。请选择 {supportedFileExtensions} 文件,然后重试", "xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.uploadFileSizeLimit": "最大文件大小:{maxFileSize}", @@ -39780,7 +39779,6 @@ "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntitiesModal.clearAllEntities": "清除所有实体", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntitiesModal.close": "关闭", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntitiesModal.title": "清除实体数据?", - "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.clearEntityData": "移除从仓库中提取的所有实体数据。此操作将永久删除持久化用户和主机记录,并且数据将不再可用于分析。请谨慎操作,因为此操作无法撤消。请注意,此操作不会删除源数据、实体风险分数或资产关键度分配。", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.featureFlagDisabled": "实体仓库功能不可用", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.subTitle": "允许全面监测您系统的主机和用户。", "xpack.securitySolution.entityAnalytics.entityStoreManagementPage.title": "实体仓库", diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts index 3574199709aee..ed598c941ed56 100644 --- a/x-pack/test/api_integration/services/security_solution_api.gen.ts +++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts @@ -80,7 +80,7 @@ import { GetEndpointSuggestionsRequestBodyInput, } from '@kbn/security-solution-plugin/common/api/endpoint/suggestions/get_suggestions.gen'; import { GetEntityEngineRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/get.gen'; -import { GetEntityEngineStatsRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/stats.gen'; +import { GetEntityStoreStatusRequestQueryInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/status.gen'; import { GetNotesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/timeline/get_notes/get_notes_route.gen'; import { GetPolicyResponseRequestQueryInput } from '@kbn/security-solution-plugin/common/api/endpoint/policy/policy_response.gen'; import { GetProtectionUpdatesNoteRequestParamsInput } from '@kbn/security-solution-plugin/common/api/endpoint/protection_updates_note/protection_updates_note.gen'; @@ -106,7 +106,7 @@ import { InitEntityEngineRequestParamsInput, InitEntityEngineRequestBodyInput, } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/init.gen'; -import { InitEntityStoreRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/enablement.gen'; +import { InitEntityStoreRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/enable.gen'; import { InstallPrepackedTimelinesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/install_prepackaged_timelines/install_prepackaged_timelines_route.gen'; import { ListEntitiesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/entities/list_entities.gen'; import { PatchRuleRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/patch_rule/patch_rule_route.gen'; @@ -832,24 +832,13 @@ finalize it. .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); }, - getEntityEngineStats(props: GetEntityEngineStatsProps, kibanaSpace: string = 'default') { - return supertest - .post( - routeWithNamespace( - replaceParams('/api/entity_store/engines/{entityType}/stats', props.params), - kibanaSpace - ) - ) - .set('kbn-xsrf', 'true') - .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') - .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); - }, - getEntityStoreStatus(kibanaSpace: string = 'default') { + getEntityStoreStatus(props: GetEntityStoreStatusProps, kibanaSpace: string = 'default') { return supertest .get(routeWithNamespace('/api/entity_store/status', kibanaSpace)) .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') - .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .query(props.query); }, /** * Get all notes for a given document. @@ -1615,8 +1604,8 @@ export interface GetEndpointSuggestionsProps { export interface GetEntityEngineProps { params: GetEntityEngineRequestParamsInput; } -export interface GetEntityEngineStatsProps { - params: GetEntityEngineStatsRequestParamsInput; +export interface GetEntityStoreStatusProps { + query: GetEntityStoreStatusRequestQueryInput; } export interface GetNotesProps { query: GetNotesRequestQueryInput; diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts index 5d0dcec11d9b6..104fbf05b5159 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entity_store.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; import { EntityStoreUtils } from '../../utils'; import { dataViewRouteHelpersFactory } from '../../utils/data_view'; @@ -14,8 +14,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const utils = EntityStoreUtils(getService); - // Failing: See https://github.com/elastic/kibana/issues/200758 - describe.skip('@ess @skipInServerlessMKI Entity Store APIs', () => { + describe('@ess @skipInServerlessMKI Entity Store APIs', () => { const dataView = dataViewRouteHelpersFactory(supertest); before(async () => { @@ -85,7 +84,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - expect(getResponse.body).to.eql({ + expect(getResponse.body).toEqual({ status: 'started', type: 'host', indexPattern: '', @@ -101,7 +100,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - expect(getResponse.body).to.eql({ + expect(getResponse.body).toEqual({ status: 'started', type: 'user', indexPattern: '', @@ -118,7 +117,7 @@ export default ({ getService }: FtrProviderContext) => { // @ts-expect-error body is any const sortedEngines = body.engines.sort((a, b) => a.type.localeCompare(b.type)); - expect(sortedEngines).to.eql([ + expect(sortedEngines).toEqual([ { status: 'started', type: 'host', @@ -160,7 +159,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - expect(body.status).to.eql('stopped'); + expect(body.status).toEqual('stopped'); }); it('should start the entity engine', async () => { @@ -176,7 +175,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - expect(body.status).to.eql('started'); + expect(body.status).toEqual('started'); }); }); @@ -213,10 +212,11 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await utils.cleanEngines(); }); + it('should return "not_installed" when no engines have been initialized', async () => { - const { body } = await api.getEntityStoreStatus().expect(200); + const { body } = await api.getEntityStoreStatus({ query: {} }).expect(200); - expect(body).to.eql({ + expect(body).toEqual({ engines: [], status: 'not_installed', }); @@ -225,23 +225,56 @@ export default ({ getService }: FtrProviderContext) => { it('should return "installing" when at least one engine is being initialized', async () => { await utils.enableEntityStore(); - const { body } = await api.getEntityStoreStatus().expect(200); + const { body } = await api.getEntityStoreStatus({ query: {} }).expect(200); - expect(body.status).to.eql('installing'); - expect(body.engines.length).to.eql(2); - expect(body.engines[0].status).to.eql('installing'); - expect(body.engines[1].status).to.eql('installing'); + expect(body.status).toEqual('installing'); + expect(body.engines.length).toEqual(2); + expect(body.engines[0].status).toEqual('installing'); + expect(body.engines[1].status).toEqual('installing'); }); it('should return "started" when all engines are started', async () => { await utils.initEntityEngineForEntityTypesAndWait(['host', 'user']); - const { body } = await api.getEntityStoreStatus().expect(200); + const { body } = await api.getEntityStoreStatus({ query: {} }).expect(200); + + expect(body.status).toEqual('running'); + expect(body.engines.length).toEqual(2); + expect(body.engines[0].status).toEqual('started'); + expect(body.engines[1].status).toEqual('started'); + }); - expect(body.status).to.eql('running'); - expect(body.engines.length).to.eql(2); - expect(body.engines[0].status).to.eql('started'); - expect(body.engines[1].status).to.eql('started'); + describe('status with components', () => { + it('should return empty list when when no engines have been initialized', async () => { + const { body } = await api + .getEntityStoreStatus({ query: { include_components: true } }) + .expect(200); + + expect(body).toEqual({ + engines: [], + status: 'not_installed', + }); + }); + + it('should return components status when engines are installed', async () => { + await utils.initEntityEngineForEntityTypesAndWait(['host']); + + const { body } = await api + .getEntityStoreStatus({ query: { include_components: true } }) + .expect(200); + + expect(body.engines[0].components).toEqual([ + expect.objectContaining({ resource: 'entity_definition' }), + expect.objectContaining({ resource: 'transform' }), + expect.objectContaining({ resource: 'ingest_pipeline' }), + expect.objectContaining({ resource: 'index_template' }), + expect.objectContaining({ resource: 'task' }), + expect.objectContaining({ resource: 'ingest_pipeline' }), + expect.objectContaining({ resource: 'enrich_policy' }), + expect.objectContaining({ resource: 'index' }), + expect.objectContaining({ resource: 'component_template' }), + ]); + }); }); }); @@ -262,14 +295,14 @@ export default ({ getService }: FtrProviderContext) => { it("should not update the index patten when it didn't change", async () => { const response = await api.applyEntityEngineDataviewIndices(); - expect(response.body).to.eql({ success: true, result: [{ type: 'host', changes: {} }] }); + expect(response.body).toEqual({ success: true, result: [{ type: 'host', changes: {} }] }); }); it('should update the index pattern when the data view changes', async () => { await dataView.updateIndexPattern('security-solution', 'test-*'); const response = await api.applyEntityEngineDataviewIndices(); - expect(response.body).to.eql({ + expect(response.body).toEqual({ success: true, result: [ {