diff --git a/plugins/.empty b/plugins/.empty deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index 1b3628306052..3f54077c2aca 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -8,3 +8,4 @@ export const APP_ID = 'siem'; export const APP_NAME = 'SIEM'; export const DEFAULT_INDEX_KEY = 'siem:defaultIndex'; export const DEFAULT_ANOMALY_SCORE = 'siem:defaultAnomalyScore'; +export const DEFAULT_MAX_TABLE_QUERY_SIZE = 10000; diff --git a/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts b/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts index bf480b2878c9..a3c597c3772f 100644 --- a/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts @@ -30,6 +30,17 @@ export const sharedSchema = gql` tiebreaker: String } + input PaginationInputPaginated { + "The activePage parameter defines the page of results you want to fetch" + activePage: Float! + "The cursorStart parameter defines the start of the results to be displayed" + cursorStart: Float! + "The fakePossibleCount parameter determines the total count in order to show 5 additional pages" + fakePossibleCount: Float! + "The querySize parameter is the number of items to be returned" + querySize: Float! + } + enum Direction { asc desc @@ -61,4 +72,10 @@ export const sharedSchema = gql` dsl: [String!]! response: [String!]! } + + type PageInfoPaginated { + activePage: Float! + fakeTotalCount: Float! + showMorePagesIndicator: Boolean! + } `; diff --git a/x-pack/legacy/plugins/siem/public/components/filters_global/filters_global.tsx b/x-pack/legacy/plugins/siem/public/components/filters_global/filters_global.tsx index 5fd760bb613a..45fddc5618f7 100644 --- a/x-pack/legacy/plugins/siem/public/components/filters_global/filters_global.tsx +++ b/x-pack/legacy/plugins/siem/public/components/filters_global/filters_global.tsx @@ -9,7 +9,7 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; import { Sticky } from 'react-sticky'; import { pure } from 'recompose'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { SuperDatePicker } from '../super_date_picker'; @@ -20,7 +20,7 @@ const disableSticky = 'screen and (max-width: ' + euiLightVars.euiBreakpoints.s const disableStickyMq = window.matchMedia(disableSticky); const Aside = styled.aside<{ isSticky?: boolean }>` - ${props => ` + ${props => css` position: relative; z-index: ${props.theme.eui.euiZNavigation}; background: ${props.theme.eui.euiColorEmptyShade}; diff --git a/x-pack/legacy/plugins/siem/public/components/header_panel/header_panel.tsx b/x-pack/legacy/plugins/siem/public/components/header_panel/header_panel.tsx index 274f4aa53878..c5c7aaf5d244 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_panel/header_panel.tsx +++ b/x-pack/legacy/plugins/siem/public/components/header_panel/header_panel.tsx @@ -7,12 +7,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiText, EuiTitle } from '@elastic/eui'; import React from 'react'; import { pure } from 'recompose'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { InspectButton } from '../inspect'; const Header = styled.header<{ border?: boolean }>` - ${props => ` + ${props => css` margin-bottom: ${props.theme.eui.euiSizeL}; ${props.border && diff --git a/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.test.tsx index 3888ab365195..6f4f7d953cb8 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.test.tsx @@ -87,6 +87,7 @@ describe('AddToKql Component', async () => { expect(store.getState().hosts.page).toEqual({ queries: { authentications: { + activePage: 0, limit: 10, }, hosts: { diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/__snapshots__/index.test.tsx.snap index 6d8e0475fa2b..23392c5880f7 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/__snapshots__/index.test.tsx.snap @@ -100,12 +100,12 @@ exports[`Authentication Table Component rendering it renders the authentication }, ] } - hasNextPage={true} + fakeTotalCount={50} id="authentication" - loadMore={[MockFunction]} + loadPage={[MockFunction]} loading={false} - nextCursor="aa7ca589f1b8220002f2fc61c64cfbf1" - totalCount={4} + showMorePagesIndicator={true} + totalCount={54} type="page" /> `; diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.test.tsx index 02a9b26a998d..360f6b9c6dfd 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.test.tsx @@ -18,7 +18,7 @@ import * as i18n from './translations'; import { AuthenticationTable, getAuthenticationColumnsCurated } from '.'; describe('Authentication Table Component', () => { - const loadMore = jest.fn(); + const loadPage = jest.fn(); const state: State = mockGlobalState; let store = createStore(state, apolloClientObservable); @@ -33,11 +33,15 @@ describe('Authentication Table Component', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.tsx index da78e9a71982..532f126c6b16 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.tsx @@ -19,21 +19,23 @@ import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_ import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { getEmptyTagValue } from '../../../empty_value'; import { HostDetailsLink, IPDetailsLink } from '../../../links'; -import { Columns, ItemsPerRow, LoadMoreTable } from '../../../load_more_table'; +import { Columns, ItemsPerRow, PaginatedTable } from '../../../paginated_table'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { Provider } from '../../../timeline/data_providers/provider'; import * as i18n from './translations'; import { getRowItemDraggables } from '../../../tables/helpers'; +const tableType = hostsModel.HostsTableType.authentications; + interface OwnProps { data: AuthenticationsEdges[]; + fakeTotalCount: number; loading: boolean; + loadPage: (newActivePage: number) => void; id: string; - hasNextPage: boolean; - nextCursor: string; + showMorePagesIndicator: boolean; totalCount: number; - loadMore: (cursor: string) => void; type: hostsModel.HostsType; } @@ -43,8 +45,30 @@ interface AuthenticationTableReduxProps { interface AuthenticationTableDispatchProps { updateLimitPagination: ActionCreator<{ limit: number; hostsType: hostsModel.HostsType }>; + updateTableActivePage: ActionCreator<{ + activePage: number; + hostsType: hostsModel.HostsType; + tableType: hostsModel.HostsTableType; + }>; + updateTableLimit: ActionCreator<{ + limit: number; + hostsType: hostsModel.HostsType; + tableType: hostsModel.HostsTableType; + }>; } +export declare type AuthTableColumns = [ + Columns, + Columns, + Columns, + Columns, + Columns, + Columns, + Columns, + Columns, + Columns +]; + type AuthenticationTableProps = OwnProps & AuthenticationTableReduxProps & AuthenticationTableDispatchProps; @@ -58,32 +82,24 @@ const rowItems: ItemsPerRow[] = [ text: i18n.ROWS_10, numberOfRow: 10, }, - { - text: i18n.ROWS_20, - numberOfRow: 20, - }, - { - text: i18n.ROWS_50, - numberOfRow: 50, - }, ]; const AuthenticationTableComponent = pure( ({ + fakeTotalCount, data, - hasNextPage, id, limit, loading, - loadMore, + loadPage, + showMorePagesIndicator, totalCount, - nextCursor, - updateLimitPagination, type, + updateTableActivePage, + updateTableLimit, }) => ( - ( limit={limit} loading={loading} loadingTitle={i18n.AUTHENTICATIONS} - loadMore={() => loadMore(nextCursor)} + loadPage={newActivePage => loadPage(newActivePage)} pageOfItems={data} + showMorePagesIndicator={showMorePagesIndicator} + totalCount={fakeTotalCount} updateLimitPagination={newLimit => - updateLimitPagination({ limit: newLimit, hostsType: type }) + updateTableLimit({ + hostsType: type, + limit: newLimit, + tableType, + }) + } + updateActivePage={newPage => + updateTableActivePage({ + activePage: newPage, + hostsType: type, + tableType, + }) } + updateProps={{ totalCount }} /> ) ); @@ -112,20 +142,12 @@ export const AuthenticationTable = connect( makeMapStateToProps, { updateLimitPagination: hostsActions.updateAuthenticationsLimit, + updateTableActivePage: hostsActions.updateTableActivePage, + updateTableLimit: hostsActions.updateTableLimit, } )(AuthenticationTableComponent); -const getAuthenticationColumns = (): [ - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns -] => [ +const getAuthenticationColumns = (): AuthTableColumns => [ { name: i18n.USER, truncateText: false, diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/mock.ts b/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/mock.ts index 29d1a3ce9737..50a1fa8eb7d7 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/mock.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/mock.ts @@ -8,7 +8,7 @@ import { AuthenticationsData } from '../../../../graphql/types'; export const mockData: { Authentications: AuthenticationsData } = { Authentications: { - totalCount: 4, + totalCount: 54, edges: [ { node: { @@ -74,10 +74,9 @@ export const mockData: { Authentications: AuthenticationsData } = { }, ], pageInfo: { - endCursor: { - value: 'aa7ca589f1b8220002f2fc61c64cfbf1', - }, - hasNextPage: true, + activePage: 1, + fakeTotalCount: 50, + showMorePagesIndicator: true, }, }, }; diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000000..fa30c0895d45 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Paginated Table Component rendering it renders the default load more table 1`] = ` + + My test supplement. +

+ } + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={ + Array [ + Object { + "numberOfRow": 2, + "text": "2 rows", + }, + Object { + "numberOfRow": 5, + "text": "5 rows", + }, + Object { + "numberOfRow": 10, + "text": "10 rows", + }, + Object { + "numberOfRow": 20, + "text": "20 rows", + }, + Object { + "numberOfRow": 50, + "text": "50 rows", + }, + ] + } + limit={1} + loadPage={[Function]} + loading={false} + loadingTitle="Hosts" + pageOfItems={ + Array [ + Object { + "cursor": Object { + "value": "98966fa2013c396155c460d35c0902be", + }, + "host": Object { + "_id": "cPsuhGcB0WOhS6qyTKC0", + "firstSeen": "2018-12-06T15:40:53.319Z", + "name": "elrond.elstc.co", + "os": "Ubuntu", + "version": "18.04.1 LTS (Bionic Beaver)", + }, + }, + Object { + "cursor": Object { + "value": "aa7ca589f1b8220002f2fc61c64cfbf1", + }, + "host": Object { + "_id": "KwQDiWcB0WOhS6qyXmrW", + "firstSeen": "2018-12-07T14:12:38.560Z", + "name": "siem-kibana", + "os": "Debian GNU/Linux", + "version": "9 (stretch)", + }, + }, + ] + } + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={[Function]} + updateLimitPagination={[Function]} +/> +`; diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/helpers.test.ts b/x-pack/legacy/plugins/siem/public/components/paginated_table/helpers.test.ts new file mode 100644 index 000000000000..f3a5b87fc5a3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/helpers.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { generateTablePaginationOptions } from './helpers'; + +describe('generateTablePaginationOptions pagination helper function', () => { + let activePage; + let limit; + test('generates 5 pages when activePage 0', () => { + activePage = 0; + limit = 10; + const result = generateTablePaginationOptions(activePage, limit); + expect(result).toEqual({ + activePage, + cursorStart: 0, + fakePossibleCount: 50, + querySize: 10, + }); + }); + test('generates 6 pages when activePage 4', () => { + activePage = 4; + limit = 10; + const result = generateTablePaginationOptions(activePage, limit); + expect(result).toEqual({ + activePage, + cursorStart: 40, + fakePossibleCount: 60, + querySize: 50, + }); + }); + test('generates 5 pages when activePage 2', () => { + activePage = 2; + limit = 10; + const result = generateTablePaginationOptions(activePage, limit); + expect(result).toEqual({ + activePage, + cursorStart: 20, + fakePossibleCount: 50, + querySize: 30, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/helpers.ts b/x-pack/legacy/plugins/siem/public/components/paginated_table/helpers.ts new file mode 100644 index 000000000000..c63b8699e79e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/helpers.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PaginationInputPaginated } from '../../graphql/types'; + +export const generateTablePaginationOptions = ( + activePage: number, + limit: number +): PaginationInputPaginated => { + const cursorStart = activePage * limit; + return { + activePage, + cursorStart, + fakePossibleCount: 4 <= activePage && activePage > 0 ? limit * (activePage + 2) : limit * 5, + querySize: limit + cursorStart, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.mock.tsx b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.mock.tsx new file mode 100644 index 000000000000..513747c70036 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.mock.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getOrEmptyTagFromValue } from '../empty_value'; + +import { Columns, ItemsPerRow } from './index'; + +export const mockData = { + Hosts: { + totalCount: 4, + edges: [ + { + host: { + _id: 'cPsuhGcB0WOhS6qyTKC0', + name: 'elrond.elstc.co', + os: 'Ubuntu', + version: '18.04.1 LTS (Bionic Beaver)', + firstSeen: '2018-12-06T15:40:53.319Z', + }, + cursor: { + value: '98966fa2013c396155c460d35c0902be', + }, + }, + { + host: { + _id: 'KwQDiWcB0WOhS6qyXmrW', + name: 'siem-kibana', + os: 'Debian GNU/Linux', + version: '9 (stretch)', + firstSeen: '2018-12-07T14:12:38.560Z', + }, + cursor: { + value: 'aa7ca589f1b8220002f2fc61c64cfbf1', + }, + }, + ], + pageInfo: { + activePage: 0, + endCursor: { + value: 'aa7ca589f1b8220002f2fc61c64cfbf1', + }, + }, + }, +}; + +export const getHostsColumns = (): [ + Columns, + Columns, + Columns, + Columns +] => [ + { + field: 'node.host.name', + name: 'Host', + truncateText: false, + hideForMobile: false, + render: (name: string) => getOrEmptyTagFromValue(name), + }, + { + field: 'node.host.firstSeen', + name: 'First seen', + truncateText: false, + hideForMobile: false, + render: (firstSeen: string) => getOrEmptyTagFromValue(firstSeen), + }, + { + field: 'node.host.os', + name: 'OS', + truncateText: false, + hideForMobile: false, + render: (os: string) => getOrEmptyTagFromValue(os), + }, + { + field: 'node.host.version', + name: 'Version', + truncateText: false, + hideForMobile: false, + render: (version: string) => getOrEmptyTagFromValue(version), + }, +]; + +export const sortedHosts: [ + Columns, + Columns, + Columns, + Columns +] = getHostsColumns().map(h => ({ ...h, sortable: true })) as [ + Columns, + Columns, + Columns, + Columns +]; + +export const rowItems: ItemsPerRow[] = [ + { + text: '2 rows', + numberOfRow: 2, + }, + { + text: '5 rows', + numberOfRow: 5, + }, + { + text: '10 rows', + numberOfRow: 10, + }, + { + text: '20 rows', + numberOfRow: 20, + }, + { + text: '50 rows', + numberOfRow: 50, + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.test.tsx new file mode 100644 index 000000000000..609aec0a2e81 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.test.tsx @@ -0,0 +1,517 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount, shallow } from 'enzyme'; +import toJson from 'enzyme-to-json'; +import * as React from 'react'; + +import { Direction } from '../../graphql/types'; + +import { BasicTableProps, PaginatedTable } from './index'; +import { getHostsColumns, mockData, rowItems, sortedHosts } from './index.mock'; +import { ThemeProvider } from 'styled-components'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../common/constants'; + +jest.mock('react', () => { + const r = jest.requireActual('react'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { ...r, memo: (x: any) => x }; +}); + +describe('Paginated Table Component', () => { + const theme = () => ({ eui: euiDarkVars, darkMode: true }); + let loadPage: jest.Mock; + let updateLimitPagination: jest.Mock; + let updateActivePage: jest.Mock; + beforeEach(() => { + loadPage = jest.fn(); + updateLimitPagination = jest.fn(); + updateActivePage = jest.fn(); + }); + + describe('rendering', () => { + test('it renders the default load more table', () => { + const wrapper = shallow( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={1} + loading={false} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + + expect(toJson(wrapper)).toMatchSnapshot(); + }); + + test('it renders the loading panel at the beginning ', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={1} + loading={true} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={[]} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + + expect( + wrapper.find('[data-test-subj="InitialLoadingPanelPaginatedTable"]').exists() + ).toBeTruthy(); + }); + + test('it renders the over loading panel after data has been in the table ', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={1} + loading={true} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + + expect(wrapper.find('[data-test-subj="LoadingPanelPaginatedTable"]').exists()).toBeTruthy(); + }); + + test('it renders the correct amount of pages and starts at activePage: 0', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={1} + loading={false} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={updateActivePage} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + + const paginiationProps = wrapper + .find('[data-test-subj="numberedPagination"]') + .first() + .props(); + + const expectedPaginationProps = { + 'data-test-subj': 'numberedPagination', + pageCount: 10, + activePage: 0, + }; + expect(JSON.stringify(paginiationProps)).toEqual(JSON.stringify(expectedPaginationProps)); + }); + + test('it render popover to select new limit in table', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={2} + loading={false} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + + wrapper + .find('[data-test-subj="loadingMoreSizeRowPopover"] button') + .first() + .simulate('click'); + expect(wrapper.find('[data-test-subj="loadingMorePickSizeRow"]').exists()).toBeTruthy(); + }); + + test('it will NOT render popover to select new limit in table if props itemsPerRow is empty', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={[]} + limit={2} + loading={false} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + + expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeFalsy(); + }); + + test('It should render a sort icon if sorting is defined', () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={2} + loading={false} + loadingTitle="Hosts" + loadPage={jest.fn()} + onChange={mockOnChange} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + sorting={{ direction: Direction.asc, field: 'node.host.name' }} + totalCount={10} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + + expect(wrapper.find('.euiTable thead tr th button svg')).toBeTruthy(); + }); + + test('Should display toast when user reaches end of results max', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={DEFAULT_MAX_TABLE_QUERY_SIZE} + loading={false} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={DEFAULT_MAX_TABLE_QUERY_SIZE * 3} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + wrapper + .find('[data-test-subj="pagination-button-next"]') + .first() + .simulate('click'); + expect(updateActivePage.mock.calls.length).toEqual(0); + }); + + test('Should show items per row if totalCount is greater than items', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={DEFAULT_MAX_TABLE_QUERY_SIZE} + loading={false} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={30} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeTruthy(); + }); + + test('Should hide items per row if totalCount is less than items', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={DEFAULT_MAX_TABLE_QUERY_SIZE} + loading={false} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={1} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeFalsy(); + }); + }); + + describe('Events', () => { + test('should call updateActivePage with 1 when clicking to the first page', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={1} + loading={false} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + wrapper + .find('[data-test-subj="pagination-button-next"]') + .first() + .simulate('click'); + expect(updateActivePage.mock.calls[0][0]).toEqual(1); + }); + + test('Should call updateActivePage with 0 when you pick a new limit', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={2} + loading={false} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + wrapper + .find('[data-test-subj="pagination-button-next"]') + .first() + .simulate('click'); + + wrapper + .find('[data-test-subj="loadingMoreSizeRowPopover"] button') + .first() + .simulate('click'); + + wrapper + .find('[data-test-subj="loadingMorePickSizeRow"] button') + .first() + .simulate('click'); + expect(updateActivePage.mock.calls[1][0]).toEqual(0); + }); + + test('should call updateActivePage with 0 when an update prop changes', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ourProps: BasicTableProps = { + columns: getHostsColumns(), + headerCount: 1, + headerSupplement:

{'My test supplement.'}

, + headerTitle: 'Hosts', + headerTooltip: 'My test tooltip', + headerUnit: 'Test Unit', + itemsPerRow: rowItems, + limit: 1, + loading: false, + loadingTitle: 'Hosts', + loadPage: newActivePage => loadPage(newActivePage), + pageOfItems: mockData.Hosts.edges, + showMorePagesIndicator: true, + totalCount: 10, + updateActivePage: activePage => updateActivePage(activePage), + updateLimitPagination: limit => updateLimitPagination({ limit }), + updateProps: { isThisAwesome: false }, + }; + + // enzyme does not allow us to pass props to child of HOC + // so we make a component to pass it the props context + // ComponentWithContext will pass the changed props to Component + // https://github.com/airbnb/enzyme/issues/1853#issuecomment-443475903 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ComponentWithContext = (props: BasicTableProps) => { + return ( + + + + ); + }; + + const wrapper = mount(); + wrapper + .find('[data-test-subj="pagination-button-next"]') + .first() + .simulate('click'); + wrapper.setProps({ updateProps: { isThisAwesome: true } }); + expect(updateActivePage.mock.calls[1][0]).toEqual(0); + }); + + test('Should call updateLimitPagination when you pick a new limit', () => { + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={2} + loading={false} + loadingTitle="Hosts" + loadPage={newActivePage => loadPage(newActivePage)} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + + wrapper + .find('[data-test-subj="loadingMoreSizeRowPopover"] button') + .first() + .simulate('click'); + + wrapper + .find('[data-test-subj="loadingMorePickSizeRow"] button') + .first() + .simulate('click'); + expect(updateLimitPagination).toBeCalled(); + }); + + test('Should call onChange when you choose a new sort in the table', () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + + {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={2} + loading={false} + loadingTitle="Hosts" + loadPage={jest.fn()} + onChange={mockOnChange} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + sorting={{ direction: Direction.asc, field: 'node.host.name' }} + totalCount={10} + updateActivePage={activePage => updateActivePage(activePage)} + updateLimitPagination={limit => updateLimitPagination({ limit })} + /> +
+ ); + + wrapper + .find('.euiTable thead tr th button') + .first() + .simulate('click'); + + expect(mockOnChange).toBeCalled(); + expect(mockOnChange.mock.calls[0]).toEqual([ + { page: undefined, sort: { direction: 'desc', field: 'node.host.name' } }, + ]); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx new file mode 100644 index 000000000000..0ad5f6daa815 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx @@ -0,0 +1,333 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + EuiBasicTable, + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiGlobalToastListToast as Toast, + EuiPagination, + EuiPanel, + EuiPopover, +} from '@elastic/eui'; +import { isEmpty, noop, getOr } from 'lodash/fp'; +import React, { memo, useState, useEffect } from 'react'; +import styled from 'styled-components'; + +import { Direction } from '../../graphql/types'; +import { AuthTableColumns } from '../page/hosts/authentications_table'; +import { HeaderPanel } from '../header_panel'; +import { LoadingPanel } from '../loading'; +import { useStateToaster } from '../toasters'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../common/constants'; + +import * as i18n from './translations'; + +const DEFAULT_DATA_TEST_SUBJ = 'paginated-table'; + +export interface ItemsPerRow { + text: string; + numberOfRow: number; +} + +export interface SortingBasicTable { + field: string; + direction: Direction; + allowNeutralSort?: boolean; +} + +export interface Criteria { + page?: { index: number; size: number }; + sort?: SortingBasicTable; +} + +declare type HostsTableColumns = [ + Columns, + Columns, + Columns, + Columns +]; + +declare type BasicTableColumns = AuthTableColumns | HostsTableColumns; + +declare type SiemTables = BasicTableProps; + +// Using telescoping templates to remove 'any' that was polluting downstream column type checks +export interface BasicTableProps { + columns: T; + dataTestSubj?: string; + headerCount: number; + headerSupplement?: React.ReactElement; + headerTitle: string | React.ReactElement; + headerTooltip?: string; + headerUnit: string | React.ReactElement; + id?: string; + itemsPerRow?: ItemsPerRow[]; + limit: number; + loading: boolean; + loadingTitle?: string; + loadPage: (activePage: number) => void; + onChange?: (criteria: Criteria) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pageOfItems: any[]; + showMorePagesIndicator: boolean; + sorting?: SortingBasicTable; + totalCount: number; + updateActivePage: (activePage: number) => void; + updateLimitPagination: (limit: number) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateProps?: { [key: string]: any }; +} + +export interface Columns { + field?: string; + name: string | React.ReactNode; + isMobileHeader?: boolean; + sortable?: boolean; + truncateText?: boolean; + hideForMobile?: boolean; + render?: (item: T) => void; + width?: string; +} + +export const PaginatedTable = memo( + ({ + columns, + dataTestSubj = DEFAULT_DATA_TEST_SUBJ, + headerCount, + headerSupplement, + headerTitle, + headerTooltip, + headerUnit, + id, + itemsPerRow, + limit, + loading, + loadingTitle, + loadPage, + onChange = noop, + pageOfItems, + showMorePagesIndicator, + sorting = null, + totalCount, + updateActivePage, + updateLimitPagination, + updateProps, + }) => { + const [activePage, setActivePage] = useState(0); + const [showInspect, setShowInspect] = useState(false); + const [isEmptyTable, setEmptyTable] = useState(pageOfItems.length === 0); + const [isPopoverOpen, setPopoverOpen] = useState(false); + const pageCount = Math.ceil(totalCount / limit); + const dispatchToaster = useStateToaster()[1]; + const effectDeps = updateProps ? [limit, ...Object.values(updateProps)] : [limit]; + useEffect(() => { + if (activePage !== 0) { + setActivePage(0); + updateActivePage(0); + } + }, effectDeps); + + const onButtonClick = () => { + setPopoverOpen(!isPopoverOpen); + }; + + const closePopover = () => { + setPopoverOpen(false); + }; + + const goToPage = (newActivePage: number) => { + if ((newActivePage + 1) * limit >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + const toast: Toast = { + id: 'PaginationWarningMsg', + title: headerTitle + i18n.TOAST_TITLE, + color: 'warning', + iconType: 'alert', + toastLifeTimeMs: 10000, + text: i18n.TOAST_TEXT, + }; + return dispatchToaster({ + type: 'addToaster', + toast, + }); + } + setActivePage(newActivePage); + loadPage(newActivePage); + updateActivePage(newActivePage); + }; + if (!isEmpty(pageOfItems) && isEmptyTable) { + setEmptyTable(false); + } + if (loading && isEmptyTable) { + return ( + + + + ); + } + + const button = ( + + {`${i18n.ROWS}: ${limit}`} + + ); + + const rowItems = + itemsPerRow && + itemsPerRow.map((item: ItemsPerRow) => ( + { + closePopover(); + updateLimitPagination(item.numberOfRow); + updateActivePage(0); // reset results to first page + }} + > + {item.text} + + )); + const PaginationWrapper = showMorePagesIndicator ? PaginationEuiFlexItem : EuiFlexItem; + return ( + setShowInspect(true)} + onMouseLeave={() => setShowInspect(false)} + > + + {loading && ( + <> + + + + )} + + + {headerSupplement} + + + + + + + {itemsPerRow && itemsPerRow.length > 0 && totalCount >= itemsPerRow[0].numberOfRow && ( + + + + )} + + + + + + + + + ); + } +); + +export const BasicTableContainer = styled.div` + position: relative; +`; + +const FooterAction = styled.div` + margin-top: 0.5rem; + width: 100%; +`; + +/* + * The getOr is just there to simplify the test + * So we do NOT need to wrap it around TestProvider + */ +const BackgroundRefetch = styled.div` + background-color: ${props => getOr('#ffffff', 'theme.eui.euiColorLightShade', props)}; + margin: -5px; + height: calc(100% + 10px); + opacity: 0.7; + width: calc(100% + 10px); + position: absolute; + z-index: 3; + border-radius: 5px; +`; + +const BasicTable = styled(EuiBasicTable)` + tbody { + th, + td { + vertical-align: top; + } + } +`; + +const PaginationEuiFlexItem = styled(EuiFlexItem)` + button.euiButtonIcon.euiButtonIcon--text { + margin-left: 20px; + } + .euiPagination { + position: relative; + } + .euiPagination::before { + content: '\\2026'; + bottom: 5px; + color: ${props => props.theme.eui.euiButtonColorDisabled}; + font-size: ${props => props.theme.eui.euiFontSizeS}; + position: absolute; + right: 30px; + } +`; diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/paginated_table/translations.ts new file mode 100644 index 000000000000..1b9faf83f502 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/translations.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const LOADING = i18n.translate('xpack.siem.loadingMoreTable.loadingDescription', { + defaultMessage: 'Loading…', +}); + +export const LOAD_MORE = i18n.translate('xpack.siem.loadingMoreTable.loadMoreDescription', { + defaultMessage: 'Load More', +}); + +export const SHOWING = i18n.translate('xpack.siem.loadingMoreTable.showing', { + defaultMessage: 'Showing', +}); + +export const ROWS = i18n.translate('xpack.siem.loadingMoreTable.rows', { + defaultMessage: 'Rows', +}); + +export const TOAST_TITLE = i18n.translate('xpack.siem.unableToLoadMoreResults.title', { + defaultMessage: ' - too many results', +}); + +export const TOAST_TEXT = i18n.translate('xpack.siem.unableToLoadMoreResults.text', { + defaultMessage: 'Narrow your query to better filter the results', +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/authentications/index.gql_query.ts b/x-pack/legacy/plugins/siem/public/containers/authentications/index.gql_query.ts index f713e0f4421e..eee35730cfdb 100644 --- a/x-pack/legacy/plugins/siem/public/containers/authentications/index.gql_query.ts +++ b/x-pack/legacy/plugins/siem/public/containers/authentications/index.gql_query.ts @@ -10,7 +10,7 @@ export const authenticationsQuery = gql` query GetAuthenticationsQuery( $sourceId: ID! $timerange: TimerangeInput! - $pagination: PaginationInput! + $pagination: PaginationInputPaginated! $filterQuery: String $defaultIndex: [String!]! $inspect: Boolean! @@ -58,10 +58,9 @@ export const authenticationsQuery = gql` } } pageInfo { - endCursor { - value - } - hasNextPage + activePage + fakeTotalCount + showMorePagesIndicator } inspect @include(if: $inspect) { dsl diff --git a/x-pack/legacy/plugins/siem/public/containers/authentications/index.tsx b/x-pack/legacy/plugins/siem/public/containers/authentications/index.tsx index c74ec85deba7..c18eaf7942b1 100644 --- a/x-pack/legacy/plugins/siem/public/containers/authentications/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/authentications/index.tsx @@ -11,10 +11,15 @@ import { connect } from 'react-redux'; import chrome from 'ui/chrome'; import { DEFAULT_INDEX_KEY } from '../../../common/constants'; -import { AuthenticationsEdges, GetAuthenticationsQuery, PageInfo } from '../../graphql/types'; +import { + AuthenticationsEdges, + GetAuthenticationsQuery, + PageInfoPaginated, +} from '../../graphql/types'; import { hostsModel, hostsSelectors, inputsModel, State, inputsSelectors } from '../../store'; import { createFilter, getDefaultFetchPolicy } from '../helpers'; -import { QueryTemplate, QueryTemplateProps } from '../query_template'; +import { generateTablePaginationOptions } from '../../components/paginated_table/helpers'; +import { QueryTemplatePaginated, QueryTemplatePaginatedProps } from '../query_template_paginated'; import { authenticationsQuery } from './index.gql_query'; @@ -25,40 +30,42 @@ export interface AuthenticationArgs { inspect: inputsModel.InspectQuery; authentications: AuthenticationsEdges[]; totalCount: number; - pageInfo: PageInfo; + pageInfo: PageInfoPaginated; loading: boolean; - loadMore: (cursor: string) => void; + loadPage: (newActivePage: number) => void; refetch: inputsModel.Refetch; } -export interface OwnProps extends QueryTemplateProps { +export interface OwnProps extends QueryTemplatePaginatedProps { children: (args: AuthenticationArgs) => React.ReactNode; type: hostsModel.HostsType; } export interface AuthenticationsComponentReduxProps { + activePage: number; isInspected: boolean; limit: number; } type AuthenticationsProps = OwnProps & AuthenticationsComponentReduxProps; -class AuthenticationsComponentQuery extends QueryTemplate< +class AuthenticationsComponentQuery extends QueryTemplatePaginated< AuthenticationsProps, GetAuthenticationsQuery.Query, GetAuthenticationsQuery.Variables > { public render() { const { - id = ID, - isInspected, + activePage, children, + endDate, filterQuery, + id = ID, + isInspected, + limit, skip, sourceId, startDate, - endDate, - limit, } = this.props; return ( @@ -73,11 +80,7 @@ class AuthenticationsComponentQuery extends QueryTemplate< from: startDate!, to: endDate!, }, - pagination: { - limit, - cursor: null, - tiebreaker: null, - }, + pagination: generateTablePaginationOptions(activePage, limit), filterQuery: createFilter(filterQuery), defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), inspect: isInspected, @@ -86,12 +89,9 @@ class AuthenticationsComponentQuery extends QueryTemplate< {({ data, loading, fetchMore, refetch }) => { const authentications = getOr([], 'source.Authentications.edges', data); this.setFetchMore(fetchMore); - this.setFetchMoreOptions((newCursor: string) => ({ + this.setFetchMoreOptions((newActivePage: number) => ({ variables: { - pagination: { - cursor: newCursor, - limit: limit + parseInt(newCursor, 10), - }, + pagination: generateTablePaginationOptions(newActivePage, limit), }, updateQuery: (prev, { fetchMoreResult }) => { if (!fetchMoreResult) { @@ -103,24 +103,21 @@ class AuthenticationsComponentQuery extends QueryTemplate< ...fetchMoreResult.source, Authentications: { ...fetchMoreResult.source.Authentications, - edges: [ - ...prev.source.Authentications.edges, - ...fetchMoreResult.source.Authentications.edges, - ], + edges: [...fetchMoreResult.source.Authentications.edges], }, }, }; }, })); return children({ + authentications, id, inspect: getOr(null, 'source.Authentications.inspect', data), - refetch, loading, - totalCount: getOr(0, 'source.Authentications.totalCount', data), - authentications, + loadPage: this.wrappedLoadMore, pageInfo: getOr({}, 'source.Authentications.pageInfo', data), - loadMore: this.wrappedLoadMore, + refetch, + totalCount: getOr(0, 'source.Authentications.totalCount', data), }); }} diff --git a/x-pack/legacy/plugins/siem/public/containers/query_template_paginated.tsx b/x-pack/legacy/plugins/siem/public/containers/query_template_paginated.tsx new file mode 100644 index 000000000000..00cd8d21fb4e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/query_template_paginated.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ApolloQueryResult } from 'apollo-client'; +import React from 'react'; +import { FetchMoreOptions, FetchMoreQueryOptions, OperationVariables } from 'react-apollo'; + +import { ESQuery } from '../../common/typed_json'; + +export interface QueryTemplatePaginatedProps { + id?: string; + endDate?: number; + filterQuery?: ESQuery | string; + skip?: boolean; + sourceId: string; + startDate?: number; +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type FetchMoreOptionsArgs = FetchMoreQueryOptions & + FetchMoreOptions; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PromiseApolloQueryResult = Promise>; + +export class QueryTemplatePaginated< + T extends QueryTemplatePaginatedProps, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TData = any, + TVariables = OperationVariables +> extends React.PureComponent { + private fetchMore!: ( + fetchMoreOptions: FetchMoreOptionsArgs + ) => PromiseApolloQueryResult; + + private fetchMoreOptions!: (newActivePage: number) => FetchMoreOptionsArgs; + + public constructor(props: T) { + super(props); + } + + public setFetchMore = ( + val: (fetchMoreOptions: FetchMoreOptionsArgs) => PromiseApolloQueryResult + ) => { + this.fetchMore = val; + }; + + public setFetchMoreOptions = ( + val: (newActivePage: number) => FetchMoreOptionsArgs + ) => { + this.fetchMoreOptions = val; + }; + + public wrappedLoadMore = (newActivePage: number) => { + return this.fetchMore(this.fetchMoreOptions(newActivePage)); + }; +} diff --git a/x-pack/legacy/plugins/siem/public/graphql/introspection.json b/x-pack/legacy/plugins/siem/public/graphql/introspection.json index 1f5c3b604833..3b86740c73dc 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/siem/public/graphql/introspection.json @@ -670,7 +670,11 @@ "type": { "kind": "NON_NULL", "name": null, - "ofType": { "kind": "INPUT_OBJECT", "name": "PaginationInput", "ofType": null } + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PaginationInputPaginated", + "ofType": null + } }, "defaultValue": null }, @@ -2333,13 +2337,13 @@ }, { "kind": "INPUT_OBJECT", - "name": "PaginationInput", + "name": "PaginationInputPaginated", "description": "", "fields": null, "inputFields": [ { - "name": "limit", - "description": "The limit parameter allows you to configure the maximum amount of items to be returned", + "name": "activePage", + "description": "The activePage parameter defines the page of results you want to fetch", "type": { "kind": "NON_NULL", "name": null, @@ -2348,15 +2352,33 @@ "defaultValue": null }, { - "name": "cursor", - "description": "The cursor parameter defines the next result you want to fetch", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, + "name": "cursorStart", + "description": "The cursorStart parameter defines the start of the results to be displayed", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } + }, "defaultValue": null }, { - "name": "tiebreaker", - "description": "The tiebreaker parameter allow to be more precise to fetch the next item", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, + "name": "fakePossibleCount", + "description": "The fakePossibleCount parameter determines the total count in order to show 5 additional pages", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } + }, + "defaultValue": null + }, + { + "name": "querySize", + "description": "The querySize parameter is the number of items to be returned", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } + }, "defaultValue": null } ], @@ -2408,7 +2430,7 @@ "type": { "kind": "NON_NULL", "name": null, - "ofType": { "kind": "OBJECT", "name": "PageInfo", "ofType": null } + "ofType": { "kind": "OBJECT", "name": "PageInfoPaginated", "ofType": null } }, "isDeprecated": false, "deprecationReason": null @@ -2969,22 +2991,42 @@ }, { "kind": "OBJECT", - "name": "PageInfo", + "name": "PageInfoPaginated", "description": "", "fields": [ { - "name": "endCursor", + "name": "activePage", "description": "", "args": [], - "type": { "kind": "OBJECT", "name": "CursorType", "ofType": null }, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } + }, "isDeprecated": false, "deprecationReason": null }, { - "name": "hasNextPage", + "name": "fakeTotalCount", "description": "", "args": [], - "type": { "kind": "SCALAR", "name": "Boolean", "ofType": null }, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "showMorePagesIndicator", + "description": "", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Boolean", "ofType": null } + }, "isDeprecated": false, "deprecationReason": null } @@ -3045,6 +3087,39 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "PaginationInput", + "description": "", + "fields": null, + "inputFields": [ + { + "name": "limit", + "description": "The limit parameter allows you to configure the maximum amount of items to be returned", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } + }, + "defaultValue": null + }, + { + "name": "cursor", + "description": "The cursor parameter defines the next result you want to fetch", + "type": { "kind": "SCALAR", "name": "String", "ofType": null }, + "defaultValue": null + }, + { + "name": "tiebreaker", + "description": "The tiebreaker parameter allow to be more precise to fetch the next item", + "type": { "kind": "SCALAR", "name": "String", "ofType": null }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "SortField", @@ -5117,6 +5192,33 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "PageInfo", + "description": "", + "fields": [ + { + "name": "endCursor", + "description": "", + "args": [], + "type": { "kind": "OBJECT", "name": "CursorType", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasNextPage", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Boolean", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "TimelineData", diff --git a/x-pack/legacy/plugins/siem/public/graphql/types.ts b/x-pack/legacy/plugins/siem/public/graphql/types.ts index 73db981ee18e..3246ef1ff49a 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/public/graphql/types.ts @@ -200,7 +200,7 @@ export interface AuthenticationsData { totalCount: number; - pageInfo: PageInfo; + pageInfo: PageInfoPaginated; inspect?: Inspect | null; } @@ -319,10 +319,12 @@ export interface CursorType { tiebreaker?: string | null; } -export interface PageInfo { - endCursor?: CursorType | null; +export interface PageInfoPaginated { + activePage: number; - hasNextPage?: boolean | null; + fakeTotalCount: number; + + showMorePagesIndicator: boolean; } export interface Inspect { @@ -799,6 +801,12 @@ export interface SshEcsFields { signature?: ToStringArray | null; } +export interface PageInfo { + endCursor?: CursorType | null; + + hasNextPage?: boolean | null; +} + export interface TimelineData { edges: TimelineEdges[]; @@ -1530,6 +1538,17 @@ export interface TimerangeInput { from: number; } +export interface PaginationInputPaginated { + /** The activePage parameter defines the page of results you want to fetch */ + activePage: number; + /** The cursorStart parameter defines the start of the results to be displayed */ + cursorStart: number; + /** The fakePossibleCount parameter determines the total count in order to show 5 additional pages */ + fakePossibleCount: number; + /** The querySize parameter is the number of items to be returned */ + querySize: number; +} + export interface PaginationInput { /** The limit parameter allows you to configure the maximum amount of items to be returned */ limit: number; @@ -1755,7 +1774,7 @@ export interface GetAllTimelineQueryArgs { export interface AuthenticationsSourceArgs { timerange: TimerangeInput; - pagination: PaginationInput; + pagination: PaginationInputPaginated; filterQuery?: string | null; @@ -2125,7 +2144,7 @@ export namespace GetAuthenticationsQuery { export type Variables = { sourceId: string; timerange: TimerangeInput; - pagination: PaginationInput; + pagination: PaginationInputPaginated; filterQuery?: string | null; defaultIndex: string[]; inspect: boolean; @@ -2242,17 +2261,13 @@ export namespace GetAuthenticationsQuery { }; export type PageInfo = { - __typename?: 'PageInfo'; + __typename?: 'PageInfoPaginated'; - endCursor?: EndCursor | null; + activePage: number; - hasNextPage?: boolean | null; - }; + fakeTotalCount: number; - export type EndCursor = { - __typename?: 'CursorType'; - - value?: string | null; + showMorePagesIndicator: boolean; }; export type Inspect = { diff --git a/x-pack/legacy/plugins/siem/public/mock/global_state.ts b/x-pack/legacy/plugins/siem/public/mock/global_state.ts index 115804d0e4cd..e52d3d95dd1f 100644 --- a/x-pack/legacy/plugins/siem/public/mock/global_state.ts +++ b/x-pack/legacy/plugins/siem/public/mock/global_state.ts @@ -31,7 +31,7 @@ export const mockGlobalState: State = { hosts: { page: { queries: { - authentications: { limit: 10 }, + authentications: { activePage: 0, limit: 10 }, hosts: { limit: 10, direction: Direction.desc, @@ -45,7 +45,7 @@ export const mockGlobalState: State = { }, details: { queries: { - authentications: { limit: 10 }, + authentications: { activePage: 0, limit: 10 }, hosts: { limit: 10, direction: Direction.desc, diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/host_details.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/host_details.tsx index 2c944f13ffba..9c7c99fef626 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/host_details.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/host_details.tsx @@ -169,22 +169,26 @@ const HostDetailsComponent = pure( totalCount, loading, pageInfo, - loadMore, + loadPage, id, inspect, refetch, }) => ( )} diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx index 982b92072e92..cee1b4bcaa7c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx @@ -151,22 +151,22 @@ const HostsComponent = pure(({ filterQuery, setAbsoluteRang totalCount, loading, pageInfo, - loadMore, + loadPage, id, inspect, refetch, }) => ( )} diff --git a/x-pack/legacy/plugins/siem/public/store/constants.ts b/x-pack/legacy/plugins/siem/public/store/constants.ts index 27718a809154..d8f13efd12dc 100644 --- a/x-pack/legacy/plugins/siem/public/store/constants.ts +++ b/x-pack/legacy/plugins/siem/public/store/constants.ts @@ -5,3 +5,4 @@ */ export const DEFAULT_TABLE_LIMIT = 10; +export const DEFAULT_TABLE_ACTIVE_PAGE = 0; diff --git a/x-pack/legacy/plugins/siem/public/store/hosts/actions.ts b/x-pack/legacy/plugins/siem/public/store/hosts/actions.ts index 0d6e74c0103e..2d1d5bd35e1b 100644 --- a/x-pack/legacy/plugins/siem/public/store/hosts/actions.ts +++ b/x-pack/legacy/plugins/siem/public/store/hosts/actions.ts @@ -9,10 +9,21 @@ import actionCreatorFactory from 'typescript-fsa'; import { HostsSortField } from '../../graphql/types'; import { KueryFilterQuery, SerializedFilterQuery } from '../model'; -import { HostsType } from './model'; - +import { HostsTableType, HostsType } from './model'; const actionCreator = actionCreatorFactory('x-pack/siem/local/hosts'); +export const updateTableActivePage = actionCreator<{ + activePage: number; + hostsType: HostsType; + tableType: HostsTableType; +}>('UPDATE_HOST_TABLE_ACTIVE_PAGE'); + +export const updateTableLimit = actionCreator<{ + hostsType: HostsType; + limit: number; + tableType: HostsTableType; +}>('UPDATE_HOST_TABLE_LIMIT'); + export const updateAuthenticationsLimit = actionCreator<{ limit: number; hostsType: HostsType }>( 'UPDATE_AUTHENTICATIONS_LIMIT' ); diff --git a/x-pack/legacy/plugins/siem/public/store/hosts/model.ts b/x-pack/legacy/plugins/siem/public/store/hosts/model.ts index 3cd00c7315c5..e9a587727d20 100644 --- a/x-pack/legacy/plugins/siem/public/store/hosts/model.ts +++ b/x-pack/legacy/plugins/siem/public/store/hosts/model.ts @@ -12,17 +12,29 @@ export enum HostsType { details = 'details', } +export enum HostsTableType { + authentications = 'authentications', + hosts = 'hosts', + events = 'events', + uncommonProcesses = 'uncommonProcesses', +} + export interface BasicQuery { limit: number; } +export interface BasicQueryPaginated { + activePage: number; + limit: number; +} + export interface HostsQuery extends BasicQuery { direction: Direction; sortField: HostsFields; } interface Queries { - authentications: BasicQuery; + authentications: BasicQueryPaginated; hosts: HostsQuery; events: BasicQuery; uncommonProcesses: BasicQuery; diff --git a/x-pack/legacy/plugins/siem/public/store/hosts/reducer.ts b/x-pack/legacy/plugins/siem/public/store/hosts/reducer.ts index 2790b86c913d..99df07758a4a 100644 --- a/x-pack/legacy/plugins/siem/public/store/hosts/reducer.ts +++ b/x-pack/legacy/plugins/siem/public/store/hosts/reducer.ts @@ -7,7 +7,7 @@ import { reducerWithInitialState } from 'typescript-fsa-reducers'; import { Direction, HostsFields } from '../../graphql/types'; -import { DEFAULT_TABLE_LIMIT } from '../constants'; +import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../constants'; import { applyHostsFilterQuery, @@ -17,6 +17,8 @@ import { updateHostsLimit, updateHostsSort, updateUncommonProcessesLimit, + updateTableActivePage, + updateTableLimit, } from './actions'; import { HostsModel } from './model'; @@ -25,7 +27,7 @@ export type HostsState = HostsModel; export const initialHostsState: HostsState = { page: { queries: { - authentications: { limit: DEFAULT_TABLE_LIMIT }, + authentications: { limit: DEFAULT_TABLE_LIMIT, activePage: DEFAULT_TABLE_ACTIVE_PAGE }, hosts: { limit: DEFAULT_TABLE_LIMIT, direction: Direction.desc, @@ -39,7 +41,7 @@ export const initialHostsState: HostsState = { }, details: { queries: { - authentications: { limit: DEFAULT_TABLE_LIMIT }, + authentications: { limit: DEFAULT_TABLE_LIMIT, activePage: DEFAULT_TABLE_ACTIVE_PAGE }, hosts: { limit: DEFAULT_TABLE_LIMIT, direction: Direction.desc, @@ -54,6 +56,32 @@ export const initialHostsState: HostsState = { }; export const hostsReducer = reducerWithInitialState(initialHostsState) + .case(updateTableActivePage, (state, { activePage, hostsType, tableType }) => ({ + ...state, + [hostsType]: { + ...state[hostsType], + queries: { + ...state[hostsType].queries, + [tableType]: { + ...state[hostsType].queries[tableType], + activePage, + }, + }, + }, + })) + .case(updateTableLimit, (state, { limit, hostsType, tableType }) => ({ + ...state, + [hostsType]: { + ...state[hostsType], + queries: { + ...state[hostsType].queries, + [tableType]: { + ...state[hostsType].queries[tableType], + limit, + }, + }, + }, + })) .case(updateAuthenticationsLimit, (state, { limit, hostsType }) => ({ ...state, [hostsType]: { diff --git a/x-pack/legacy/plugins/siem/server/graphql/authentications/resolvers.ts b/x-pack/legacy/plugins/siem/server/graphql/authentications/resolvers.ts index f60a870c8d16..b66ccd9a111b 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/authentications/resolvers.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/authentications/resolvers.ts @@ -7,7 +7,7 @@ import { SourceResolvers } from '../../graphql/types'; import { Authentications } from '../../lib/authentications'; import { AppResolverOf, ChildResolverOf } from '../../lib/framework'; -import { createOptions } from '../../utils/build_query/create_options'; +import { createOptionsPaginated } from '../../utils/build_query/create_options'; import { QuerySourceResolver } from '../sources/resolvers'; type QueryAuthenticationsResolver = ChildResolverOf< @@ -28,7 +28,7 @@ export const createAuthenticationsResolvers = ( } => ({ Source: { async Authentications(source, args, { req }, info) { - const options = createOptions(source, args, info); + const options = createOptionsPaginated(source, args, info); return libs.authentications.getAuthentications(req, options); }, }, diff --git a/x-pack/legacy/plugins/siem/server/graphql/authentications/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/authentications/schema.gql.ts index aa84756ef735..20935ce9ed03 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/authentications/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/authentications/schema.gql.ts @@ -30,7 +30,7 @@ export const authenticationsSchema = gql` type AuthenticationsData { edges: [AuthenticationsEdges!]! totalCount: Float! - pageInfo: PageInfo! + pageInfo: PageInfoPaginated! inspect: Inspect } @@ -38,7 +38,7 @@ export const authenticationsSchema = gql` "Gets Authentication success and failures based on a timerange" Authentications( timerange: TimerangeInput! - pagination: PaginationInput! + pagination: PaginationInputPaginated! filterQuery: String defaultIndex: [String!]! ): AuthenticationsData! diff --git a/x-pack/legacy/plugins/siem/server/graphql/types.ts b/x-pack/legacy/plugins/siem/server/graphql/types.ts index 99335786af75..1fd83d65b262 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/types.ts @@ -229,7 +229,7 @@ export interface AuthenticationsData { totalCount: number; - pageInfo: PageInfo; + pageInfo: PageInfoPaginated; inspect?: Inspect | null; } @@ -348,10 +348,12 @@ export interface CursorType { tiebreaker?: string | null; } -export interface PageInfo { - endCursor?: CursorType | null; +export interface PageInfoPaginated { + activePage: number; - hasNextPage?: boolean | null; + fakeTotalCount: number; + + showMorePagesIndicator: boolean; } export interface Inspect { @@ -828,6 +830,12 @@ export interface SshEcsFields { signature?: ToStringArray | null; } +export interface PageInfo { + endCursor?: CursorType | null; + + hasNextPage?: boolean | null; +} + export interface TimelineData { edges: TimelineEdges[]; @@ -1559,6 +1567,17 @@ export interface TimerangeInput { from: number; } +export interface PaginationInputPaginated { + /** The activePage parameter defines the page of results you want to fetch */ + activePage: number; + /** The cursorStart parameter defines the start of the results to be displayed */ + cursorStart: number; + /** The fakePossibleCount parameter determines the total count in order to show 5 additional pages */ + fakePossibleCount: number; + /** The querySize parameter is the number of items to be returned */ + querySize: number; +} + export interface PaginationInput { /** The limit parameter allows you to configure the maximum amount of items to be returned */ limit: number; @@ -1784,7 +1803,7 @@ export interface GetAllTimelineQueryArgs { export interface AuthenticationsSourceArgs { timerange: TimerangeInput; - pagination: PaginationInput; + pagination: PaginationInputPaginated; filterQuery?: string | null; @@ -2503,7 +2522,7 @@ export namespace SourceResolvers { export interface AuthenticationsArgs { timerange: TimerangeInput; - pagination: PaginationInput; + pagination: PaginationInputPaginated; filterQuery?: string | null; @@ -3012,7 +3031,7 @@ export namespace AuthenticationsDataResolvers { totalCount?: TotalCountResolver; - pageInfo?: PageInfoResolver; + pageInfo?: PageInfoResolver; inspect?: InspectResolver; } @@ -3028,7 +3047,7 @@ export namespace AuthenticationsDataResolvers { Context = SiemContext > = Resolver; export type PageInfoResolver< - R = PageInfo, + R = PageInfoPaginated, Parent = AuthenticationsData, Context = SiemContext > = Resolver; @@ -3418,21 +3437,28 @@ export namespace CursorTypeResolvers { > = Resolver; } -export namespace PageInfoResolvers { - export interface Resolvers { - endCursor?: EndCursorResolver; +export namespace PageInfoPaginatedResolvers { + export interface Resolvers { + activePage?: ActivePageResolver; - hasNextPage?: HasNextPageResolver; + fakeTotalCount?: FakeTotalCountResolver; + + showMorePagesIndicator?: ShowMorePagesIndicatorResolver; } - export type EndCursorResolver< - R = CursorType | null, - Parent = PageInfo, + export type ActivePageResolver< + R = number, + Parent = PageInfoPaginated, Context = SiemContext > = Resolver; - export type HasNextPageResolver< - R = boolean | null, - Parent = PageInfo, + export type FakeTotalCountResolver< + R = number, + Parent = PageInfoPaginated, + Context = SiemContext + > = Resolver; + export type ShowMorePagesIndicatorResolver< + R = boolean, + Parent = PageInfoPaginated, Context = SiemContext > = Resolver; } @@ -5024,6 +5050,25 @@ export namespace SshEcsFieldsResolvers { > = Resolver; } +export namespace PageInfoResolvers { + export interface Resolvers { + endCursor?: EndCursorResolver; + + hasNextPage?: HasNextPageResolver; + } + + export type EndCursorResolver< + R = CursorType | null, + Parent = PageInfo, + Context = SiemContext + > = Resolver; + export type HasNextPageResolver< + R = boolean | null, + Parent = PageInfo, + Context = SiemContext + > = Resolver; +} + export namespace TimelineDataResolvers { export interface Resolvers { edges?: EdgesResolver; diff --git a/x-pack/legacy/plugins/siem/server/lib/authentications/elasticsearch_adapter.ts b/x-pack/legacy/plugins/siem/server/lib/authentications/elasticsearch_adapter.ts index 3e89011e7758..79f13ce4461e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/authentications/elasticsearch_adapter.ts +++ b/x-pack/legacy/plugins/siem/server/lib/authentications/elasticsearch_adapter.ts @@ -8,8 +8,9 @@ import { getOr } from 'lodash/fp'; import { AuthenticationsData, AuthenticationsEdges } from '../../graphql/types'; import { mergeFieldsWithHit, inspectStringifyObject } from '../../utils/build_query'; -import { FrameworkAdapter, FrameworkRequest, RequestOptions } from '../framework'; +import { FrameworkAdapter, FrameworkRequest, RequestOptionsPaginated } from '../framework'; import { TermAggregation } from '../types'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../common/constants'; import { auditdFieldsMap, buildQuery } from './query.dsl'; import { @@ -24,16 +25,20 @@ export class ElasticsearchAuthenticationAdapter implements AuthenticationsAdapte public async getAuthentications( request: FrameworkRequest, - options: RequestOptions + options: RequestOptionsPaginated ): Promise { const dsl = buildQuery(options); + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } const response = await this.framework.callWithRequest( request, 'search', dsl ); - const { cursor, limit } = options.pagination; + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; const totalCount = getOr(0, 'aggregations.user_count.value', response); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; const hits: AuthenticationHit[] = getOr( [], 'aggregations.group_by_users.buckets', @@ -49,32 +54,28 @@ export class ElasticsearchAuthenticationAdapter implements AuthenticationsAdapte lastFailure: getOr(null, 'failures.lastFailure.hits.hits[0]._source', bucket), }, user: bucket.key, - cursor: bucket.key.user_uid, failures: bucket.failures.doc_count, successes: bucket.successes.doc_count, })); - const authenticationEdges: AuthenticationsEdges[] = hits.map(hit => formatAuthenticationData(options.fields, hit, auditdFieldsMap) ); - const hasNextPage = authenticationEdges.length === limit + 1; - const beginning = cursor != null ? parseInt(cursor!, 10) : 0; - const edges = authenticationEdges.splice(beginning, limit - beginning); + const edges = authenticationEdges.splice(cursorStart, querySize - cursorStart); const inspect = { dsl: [inspectStringifyObject(dsl)], response: [inspectStringifyObject(response)], }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + return { inspect, edges, totalCount, pageInfo: { - hasNextPage, - endCursor: { - value: String(limit), - tiebreaker: null, - }, + activePage: activePage ? activePage : 0, + fakeTotalCount, + showMorePagesIndicator, }, }; } diff --git a/x-pack/legacy/plugins/siem/server/lib/authentications/index.ts b/x-pack/legacy/plugins/siem/server/lib/authentications/index.ts index 4ed0c9b1ed14..d64f820de239 100644 --- a/x-pack/legacy/plugins/siem/server/lib/authentications/index.ts +++ b/x-pack/legacy/plugins/siem/server/lib/authentications/index.ts @@ -5,7 +5,7 @@ */ import { AuthenticationsData } from '../../graphql/types'; -import { FrameworkRequest, RequestOptions } from '../framework'; +import { FrameworkRequest, RequestOptionsPaginated } from '../framework'; import { AuthenticationsAdapter } from './types'; @@ -14,7 +14,7 @@ export class Authentications { public async getAuthentications( req: FrameworkRequest, - options: RequestOptions + options: RequestOptionsPaginated ): Promise { return await this.adapter.getAuthentications(req, options); } diff --git a/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts index ae184c321299..333cc79fadab 100644 --- a/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts @@ -8,7 +8,7 @@ import { createQueryFilterClauses } from '../../utils/build_query'; import { reduceFields } from '../../utils/build_query/reduce_fields'; import { hostFieldsMap, sourceFieldsMap } from '../ecs_fields'; import { extendMap } from '../ecs_fields/extend_map'; -import { RequestOptions } from '../framework'; +import { RequestOptionsPaginated } from '../framework'; export const auditdFieldsMap: Readonly> = { latest: '@timestamp', @@ -24,12 +24,12 @@ export const buildQuery = ({ fields, filterQuery, timerange: { from, to }, - pagination: { limit }, + pagination: { querySize }, defaultIndex, sourceConfiguration: { fields: { timestamp }, }, -}: RequestOptions) => { +}: RequestOptionsPaginated) => { const esFields = reduceFields(fields, { ...hostFieldsMap, ...sourceFieldsMap }); const filter = [ @@ -62,7 +62,7 @@ export const buildQuery = ({ ...agg, group_by_users: { terms: { - size: limit + 1, + size: querySize, field: 'user.name', order: [{ 'successes.doc_count': 'desc' }, { 'failures.doc_count': 'desc' }], }, @@ -107,8 +107,8 @@ export const buildQuery = ({ filter, }, }, + size: 0, }, - size: 0, track_total_hits: false, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/authentications/types.ts b/x-pack/legacy/plugins/siem/server/lib/authentications/types.ts index f120bcdfbf50..2d2c7ba547c0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/authentications/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/authentications/types.ts @@ -5,11 +5,14 @@ */ import { AuthenticationsData, LastSourceHost } from '../../graphql/types'; -import { FrameworkRequest, RequestOptions } from '../framework'; +import { FrameworkRequest, RequestOptionsPaginated } from '../framework'; import { Hit, SearchHit, TotalHit } from '../types'; export interface AuthenticationsAdapter { - getAuthentications(req: FrameworkRequest, options: RequestOptions): Promise; + getAuthentications( + req: FrameworkRequest, + options: RequestOptionsPaginated + ): Promise; } type StringOrNumber = string | number; diff --git a/x-pack/legacy/plugins/siem/server/lib/framework/types.ts b/x-pack/legacy/plugins/siem/server/lib/framework/types.ts index 16fd3c4f0ba5..559915776311 100644 --- a/x-pack/legacy/plugins/siem/server/lib/framework/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/framework/types.ts @@ -12,6 +12,7 @@ import { Legacy } from 'kibana'; import { ESQuery } from '../../../common/typed_json'; import { PaginationInput, + PaginationInputPaginated, SortField, SourceConfiguration, TimerangeInput, @@ -159,3 +160,9 @@ export interface RequestOptions extends RequestBasicOptions { fields: readonly string[]; sortField?: SortField; } + +export interface RequestOptionsPaginated extends RequestBasicOptions { + pagination: PaginationInputPaginated; + fields: ReadonlyArray; + sortField?: SortField; +} diff --git a/x-pack/legacy/plugins/siem/server/utils/build_query/create_options.ts b/x-pack/legacy/plugins/siem/server/utils/build_query/create_options.ts index f3c7832fb609..5210060c7c7b 100644 --- a/x-pack/legacy/plugins/siem/server/utils/build_query/create_options.ts +++ b/x-pack/legacy/plugins/siem/server/utils/build_query/create_options.ts @@ -7,8 +7,14 @@ import { GraphQLResolveInfo } from 'graphql'; import { getOr } from 'lodash/fp'; -import { PaginationInput, SortField, Source, TimerangeInput } from '../../graphql/types'; -import { RequestOptions } from '../../lib/framework'; +import { + PaginationInput, + PaginationInputPaginated, + SortField, + Source, + TimerangeInput, +} from '../../graphql/types'; +import { RequestOptions, RequestOptionsPaginated } from '../../lib/framework'; import { parseFilterQuery } from '../serialized_query'; import { getFields } from '.'; @@ -27,6 +33,13 @@ export interface Args { sortField?: SortField | null; defaultIndex: string[]; } +export interface ArgsPaginated { + timerange?: TimerangeInput | null; + pagination?: PaginationInputPaginated | null; + filterQuery?: string | null; + sortField?: SortField | null; + defaultIndex: string[]; +} export const createOptions = ( source: Configuration, @@ -47,3 +60,23 @@ export const createOptions = ( .map(field => field.replace(fieldReplacement, '')), }; }; + +export const createOptionsPaginated = ( + source: Configuration, + args: ArgsPaginated, + info: FieldNodes, + fieldReplacement: string = 'edges.node.' +): RequestOptionsPaginated => { + const fields = getFields(getOr([], 'fieldNodes[0]', info)); + return { + defaultIndex: args.defaultIndex, + sourceConfiguration: source.configuration, + timerange: args.timerange!, + pagination: args.pagination!, + sortField: args.sortField!, + filterQuery: parseFilterQuery(args.filterQuery || ''), + fields: fields + .filter(field => !field.includes('__typename')) + .map(field => field.replace(fieldReplacement, '')), + }; +}; diff --git a/x-pack/test/api_integration/apis/siem/authentications.ts b/x-pack/test/api_integration/apis/siem/authentications.ts index d4de9ec389b0..1ac36d31e43b 100644 --- a/x-pack/test/api_integration/apis/siem/authentications.ts +++ b/x-pack/test/api_integration/apis/siem/authentications.ts @@ -38,8 +38,10 @@ const authenticationsTests: KbnTestProvider = ({ getService }) => { from: FROM, }, pagination: { - limit: 1, - cursor: null, + activePage: 0, + cursorStart: 0, + fakePossibleCount: 3, + querySize: 1, }, defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], inspect: false, @@ -49,7 +51,7 @@ const authenticationsTests: KbnTestProvider = ({ getService }) => { const authentications = resp.data.source.Authentications; expect(authentications.edges.length).to.be(EDGE_LENGTH); expect(authentications.totalCount).to.be(TOTAL_COUNT); - expect(authentications.pageInfo.endCursor!.value).to.equal('1'); + expect(authentications.pageInfo.fakeTotalCount).to.equal(3); }); }); @@ -65,8 +67,10 @@ const authenticationsTests: KbnTestProvider = ({ getService }) => { from: FROM, }, pagination: { - limit: 2, - cursor: '1', + activePage: 2, + cursorStart: 1, + fakePossibleCount: 5, + querySize: 2, }, defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], inspect: false,