[Security Solution] Improve explore pages performance (#178893)

## Summary

issue: https://github.com/elastic/kibana/issues/178372

- Split Network flow queries, extracting the cardinality aggregation
from the data aggregation, resulting in one query for the totalCount
aggregation, and another one for the IPs data aggregation, that are
executed in parallel.

- Fixed search strategy observable bug, causing duplicate request to be
sent for every query

Screenshots:

![source
table](66b660f6-f423-4476-afc8-7fe7621fea0d)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2024-04-08 19:52:51 +02:00 committed by GitHub
parent 0bad865382
commit d1e792a5a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 495 additions and 234 deletions

View file

@ -35,7 +35,7 @@ import {
import { networkOverviewSchema } from './network/overview';
import { networkTlsSchema } from './network/tls';
import { networkTopCountriesSchema } from './network/top_countries';
import { networkTopNFlowSchema } from './network/top_n_flow';
import { networkTopNFlowCountSchema, networkTopNFlowSchema } from './network/top_n_flow';
import { networkUsersSchema } from './network/users';
import {
@ -106,6 +106,7 @@ export const searchStrategyRequestSchema = z.discriminatedUnion('factoryQueryTyp
networkTlsSchema,
networkTopCountriesSchema,
networkTopNFlowSchema,
networkTopNFlowCountSchema,
networkUsersSchema,
networkKpiDns,
networkKpiEvents,

View file

@ -41,6 +41,7 @@ export enum NetworkQueries {
overview = 'overviewNetwork',
tls = 'tls',
topCountries = 'topCountries',
topNFlowCount = 'topNFlowCount',
topNFlow = 'topNFlow',
users = 'users',
}

View file

@ -7,13 +7,14 @@
import { z } from 'zod';
import { NetworkQueries } from '../model/factory_query_type';
import { requestBasicOptionsSchema } from '../model/request_basic_options';
import { requestOptionsPaginatedSchema } from '../model/request_paginated_options';
import { sort } from '../model/sort';
import { timerange } from '../model/timerange';
import { flowTarget } from './model/flow_target';
export const networkTopNFlowSchema = requestOptionsPaginatedSchema.extend({
ip: z.string().ip().nullable().optional(),
ip: z.string().ip().nullish(),
flowTarget,
sort,
timerange,
@ -21,5 +22,13 @@ export const networkTopNFlowSchema = requestOptionsPaginatedSchema.extend({
});
export type NetworkTopNFlowRequestOptionsInput = z.input<typeof networkTopNFlowSchema>;
export type NetworkTopNFlowRequestOptions = z.infer<typeof networkTopNFlowSchema>;
export const networkTopNFlowCountSchema = requestBasicOptionsSchema.extend({
ip: z.string().ip().nullish(),
flowTarget,
timerange,
factoryQueryType: z.literal(NetworkQueries.topNFlowCount),
});
export type NetworkTopNFlowCountRequestOptionsInput = z.input<typeof networkTopNFlowCountSchema>;
export type NetworkTopNFlowCountRequestOptions = z.infer<typeof networkTopNFlowCountSchema>;

View file

@ -31,6 +31,7 @@ import type {
NetworkKpiTlsHandshakesStrategyResponse,
NetworkKpiUniqueFlowsStrategyResponse,
NetworkKpiUniquePrivateIpsStrategyResponse,
NetworkTopNFlowCountStrategyResponse,
} from './network';
import type { MatrixHistogramQuery, MatrixHistogramStrategyResponse } from './matrix_histogram';
import type {
@ -104,6 +105,8 @@ import type {
NetworkTlsRequestOptionsInput,
NetworkTopCountriesRequestOptions,
NetworkTopCountriesRequestOptionsInput,
NetworkTopNFlowCountRequestOptions,
NetworkTopNFlowCountRequestOptionsInput,
NetworkTopNFlowRequestOptions,
NetworkTopNFlowRequestOptionsInput,
NetworkUsersRequestOptions,
@ -189,6 +192,8 @@ export type StrategyResponseType<T extends FactoryQueryTypes> = T extends HostsQ
? NetworkTopCountriesStrategyResponse
: T extends NetworkQueries.topNFlow
? NetworkTopNFlowStrategyResponse
: T extends NetworkQueries.topNFlowCount
? NetworkTopNFlowCountStrategyResponse
: T extends NetworkQueries.users
? NetworkUsersStrategyResponse
: T extends NetworkKpiQueries.dns
@ -259,6 +264,8 @@ export type StrategyRequestInputType<T extends FactoryQueryTypes> = T extends Ho
? NetworkTopCountriesRequestOptionsInput
: T extends NetworkQueries.topNFlow
? NetworkTopNFlowRequestOptionsInput
: T extends NetworkQueries.topNFlowCount
? NetworkTopNFlowCountRequestOptionsInput
: T extends NetworkQueries.users
? NetworkUsersRequestOptionsInput
: T extends NetworkKpiQueries.dns
@ -329,6 +336,8 @@ export type StrategyRequestType<T extends FactoryQueryTypes> = T extends HostsQu
? NetworkTopCountriesRequestOptions
: T extends NetworkQueries.topNFlow
? NetworkTopNFlowRequestOptions
: T extends NetworkQueries.topNFlowCount
? NetworkTopNFlowCountRequestOptions
: T extends NetworkQueries.users
? NetworkUsersRequestOptions
: T extends NetworkKpiQueries.dns

View file

@ -7,19 +7,15 @@
import type { IEsSearchResponse } from '@kbn/data-plugin/common';
import type { GeoItem, TopNetworkTablesEcsField } from '../common';
import type {
CursorType,
Inspect,
Maybe,
PageInfoPaginated,
TotalValue,
GenericBuckets,
} from '../../../common';
import type { CursorType, Inspect, Maybe, TotalValue, GenericBuckets } from '../../../common';
export interface NetworkTopNFlowStrategyResponse extends IEsSearchResponse {
edges: NetworkTopNFlowEdges[];
inspect?: Maybe<Inspect>;
}
export interface NetworkTopNFlowCountStrategyResponse extends IEsSearchResponse {
totalCount: number;
pageInfo: PageInfoPaginated;
inspect?: Maybe<Inspect>;
}

View file

@ -13,9 +13,10 @@ import type {
FactoryQueryTypes,
StrategyRequestInputType,
} from '../../../../common/search_strategy';
import { Observable } from 'rxjs';
import { of, throwError } from 'rxjs';
jest.mock('@kbn/securitysolution-hook-utils');
const mockAddToastError = jest.fn();
const mockAddToastWarning = jest.fn();
jest.mock('../../hooks/use_app_toasts', () => ({
@ -25,20 +26,13 @@ jest.mock('../../hooks/use_app_toasts', () => ({
})),
}));
// default to completed response
const mockResponse = jest.fn(
() =>
({
rawResponse: {},
isPartial: false,
isRunning: false,
} as unknown)
);
const mockSearch = jest.fn(
() =>
new Observable((subscription) => {
subscription.next(mockResponse());
})
const mockSearch = jest.fn(() =>
// default to completed response
of({
rawResponse: {},
isPartial: false,
isRunning: false,
})
);
jest.mock('../../lib/kibana', () => {
const original = jest.requireActual('../../lib/kibana');
@ -269,7 +263,7 @@ describe('useSearchStrategy', () => {
describe('search function', () => {
it('should track successful search result', () => {
const { result } = renderHook(() => useSearch<FactoryQueryTypes>(factoryQueryType));
result.current({ request, abortSignal: new AbortController().signal });
result.current({ request, abortSignal: new AbortController().signal }).subscribe();
expect(mockStartTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledTimes(1);
@ -277,24 +271,25 @@ describe('useSearchStrategy', () => {
});
it('should handle search error', () => {
mockResponse.mockImplementation(() => {
throw new Error('simulated search error');
const error = 'simulated search error';
mockSearch.mockImplementationOnce(() => {
return throwError(() => Error(error));
});
const { result } = renderHook(() => useSearch<FactoryQueryTypes>(factoryQueryType));
result.current({ request, abortSignal: new AbortController().signal });
result.current({ request, abortSignal: new AbortController().signal }).subscribe();
expect(mockStartTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledWith('error');
});
it('should track error search result', () => {
mockResponse.mockImplementationOnce(() => {
throw Error('fake server error');
mockSearch.mockImplementationOnce(() => {
return throwError(() => Error('fake server error'));
});
const { result } = renderHook(() => useSearch<FactoryQueryTypes>(factoryQueryType));
result.current({ request, abortSignal: new AbortController().signal });
result.current({ request, abortSignal: new AbortController().signal }).subscribe();
expect(mockStartTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledTimes(1);
@ -303,13 +298,13 @@ describe('useSearchStrategy', () => {
it('should track aborted search result', () => {
const abortController = new AbortController();
mockResponse.mockImplementationOnce(() => {
mockSearch.mockImplementationOnce(() => {
abortController.abort();
throw Error('fake aborted');
return throwError(() => Error('fake aborted'));
});
const { result } = renderHook(() => useSearch<FactoryQueryTypes>(factoryQueryType));
result.current({ request, abortSignal: abortController.signal });
result.current({ request, abortSignal: abortController.signal }).subscribe();
expect(mockStartTracking).toBeCalledTimes(1);
expect(mockEndTracking).toBeCalledTimes(1);

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { filter } from 'rxjs';
import { catchError, filter, tap } from 'rxjs';
import { noop, omit } from 'lodash/fp';
import { useCallback, useEffect, useRef, useMemo } from 'react';
import type { Observable } from 'rxjs';
@ -55,27 +55,22 @@ export const useSearch = <QueryType extends FactoryQueryTypes>(
name: `${APP_UI_ID} searchStrategy ${factoryQueryType}`,
spanName: 'batched search',
});
const observable = data.search
// return observable directly, any extra subscription causes duplicate requests
return data.search
.search<StrategyRequestInputType<QueryType>, StrategyResponseType<QueryType>>(
{ ...request, factoryQueryType } as StrategyRequestInputType<QueryType>,
{
strategy: 'securitySolutionSearchStrategy',
abortSignal,
}
{ strategy: 'securitySolutionSearchStrategy', abortSignal }
)
.pipe(filter((response) => !isRunningResponse(response)));
observable.subscribe({
next: (response) => {
endTracking('success');
},
error: () => {
endTracking(abortSignal.aborted ? 'aborted' : 'error');
},
});
return observable;
.pipe(
filter((response) => !isRunningResponse(response)),
catchError((error) => {
endTracking(abortSignal.aborted ? 'aborted' : 'error');
throw error;
}),
tap(() => {
endTracking('success');
})
);
},
[data.search, factoryQueryType, startTracking]
);
@ -131,10 +126,7 @@ export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
(request) => {
const startSearch = () => {
abortCtrl.current = new AbortController();
start({
request,
abortSignal: abortCtrl.current.signal,
});
start({ request, abortSignal: abortCtrl.current.signal });
};
abortCtrl.current.abort();

View file

@ -9,6 +9,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta
loading={false}
>
<HeaderSectionComponent
inspectMultiple={true}
subtitle="Showing: 1 Test Unit"
title="Hosts"
toggleQuery={[Function]}

View file

@ -16,7 +16,45 @@ export const generateTablePaginationOptions = (
return {
activePage,
cursorStart,
fakePossibleCount: 4 <= activePage && activePage > 0 ? limit * (activePage + 2) : limit * 5,
fakePossibleCount: getFakePossibleCount(activePage, limit),
querySize: isBucketSort ? limit : limit + cursorStart,
};
};
export const getLimitedPaginationOptions = (
activePage: number,
limit: number
): PaginationInputPaginatedInput => {
const cursorStart = activePage * limit;
return {
activePage,
cursorStart,
querySize: limit + cursorStart,
// TODO: Limited pagination behavior is UI-only logic, the server API should not have to know anything about it.
// Remove this parameter from the API schema when all security solution requests are updated.
fakePossibleCount: 0,
};
};
export const getLimitedPaginationTotalCount = ({
activePage,
limit,
totalCount,
}: {
activePage: number;
limit: number;
totalCount: number;
}): number => {
const fakePossibleCount = getFakePossibleCount(activePage, limit);
return fakePossibleCount <= totalCount ? fakePossibleCount : totalCount;
};
/**
* This function returns a fake possible count based on the active page and limit.
* The goal is to restring the pagination to prevent querying arbitrary pages, which may cause performance issues.
* After initializing the table we allow the user to navigate to pages 1-5.
* If the user reaches page 5 or higher, we only allow to go to the following page.
*/
const getFakePossibleCount = (activePage: number, limit: number): number => {
return activePage < 4 ? limit * 5 : limit * (activePage + 2);
};

View file

@ -117,8 +117,7 @@ export interface BasicTableProps<T> {
loading: boolean;
loadPage: (activePage: number) => void;
onChange?: (criteria: Criteria) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pageOfItems: any[];
pageOfItems: unknown[];
setQuerySkip: (skip: boolean) => void;
showMorePagesIndicator: boolean;
sorting?: SortingBasicTable;
@ -292,6 +291,7 @@ const PaginatedTableComponent: FC<SiemTables> = ({
}
title={headerTitle}
tooltip={headerTooltip}
inspectMultiple
>
{!loadingInitial && headerSupplement}
</HeaderSection>

View file

@ -6,7 +6,6 @@
*/
import { shallow } from 'enzyme';
import { getOr } from 'lodash/fp';
import React from 'react';
import { Provider as ReduxStoreProvider } from 'react-redux';
@ -15,7 +14,7 @@ import { TestProviders, createMockStore } from '../../../../common/mock';
import { useMountAppended } from '../../../../common/utils/use_mount_appended';
import { networkModel } from '../../store';
import { NetworkTopNFlowTable } from '.';
import { mockData } from './mock';
import { mockData, mockCount } from './mock';
import { FlowTargetSourceDest } from '../../../../../common/search_strategy';
jest.mock('../../../../common/lib/kibana');
@ -27,15 +26,15 @@ describe('NetworkTopNFlow Table Component', () => {
const mount = useMountAppended();
const defaultProps = {
data: mockData.edges,
fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo),
fakeTotalCount: 50,
flowTargeted: FlowTargetSourceDest.source,
id: 'topNFlowSource',
isInspect: false,
loading: false,
loadPage,
setQuerySkip: jest.fn(),
showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo),
totalCount: mockData.totalCount,
showMorePagesIndicator: true,
totalCount: mockCount.totalCount,
type: networkModel.NetworkType.page,
};

View file

@ -22,17 +22,16 @@ import { PaginatedTable } from '../../../components/paginated_table';
import { networkActions, networkModel, networkSelectors } from '../../store';
import { getNFlowColumnsCurated } from './columns';
import * as i18n from './translations';
import { getLimitedPaginationTotalCount } from '../../../components/paginated_table/helpers';
interface NetworkTopNFlowTableProps {
data: NetworkTopNFlowEdges[];
fakeTotalCount: number;
flowTargeted: FlowTargetSourceDest;
id: string;
isInspect: boolean;
loading: boolean;
loadPage: (newActivePage: number) => void;
setQuerySkip: (skip: boolean) => void;
showMorePagesIndicator: boolean;
totalCount: number;
type: networkModel.NetworkType;
}
@ -52,14 +51,12 @@ export const NetworkTopNFlowTableId = 'networkTopSourceFlow-top-talkers';
const NetworkTopNFlowTableComponent: React.FC<NetworkTopNFlowTableProps> = ({
data,
fakeTotalCount,
flowTargeted,
id,
isInspect,
loading,
loadPage,
setQuerySkip,
showMorePagesIndicator,
totalCount,
type,
}) => {
@ -152,6 +149,16 @@ const NetworkTopNFlowTableComponent: React.FC<NetworkTopNFlowTableProps> = ({
[dispatch, type, tableType]
);
// limits pagination to 5 pages with 1 page increments to prevent expensive queries
const limitedPaginationTotalCount = useMemo(
() => getLimitedPaginationTotalCount({ activePage, totalCount, limit }),
[activePage, limit, totalCount]
);
const showMorePagesIndicator = useMemo(() => {
return totalCount / limit > limitedPaginationTotalCount / limit;
}, [totalCount, limitedPaginationTotalCount, limit]);
return (
<PaginatedTable
activePage={activePage}
@ -171,7 +178,7 @@ const NetworkTopNFlowTableComponent: React.FC<NetworkTopNFlowTableProps> = ({
setQuerySkip={setQuerySkip}
showMorePagesIndicator={showMorePagesIndicator}
sorting={sorting}
totalCount={fakeTotalCount}
totalCount={limitedPaginationTotalCount}
updateActivePage={updateActivePage}
updateLimitPagination={updateLimitPagination}
/>

View file

@ -5,11 +5,18 @@
* 2.0.
*/
import type { NetworkTopNFlowStrategyResponse } from '../../../../../common/search_strategy';
import type {
NetworkTopNFlowCountStrategyResponse,
NetworkTopNFlowStrategyResponse,
} from '../../../../../common/search_strategy';
import { FlowTargetSourceDest } from '../../../../../common/search_strategy';
export const mockData: NetworkTopNFlowStrategyResponse = {
export const mockCount: NetworkTopNFlowCountStrategyResponse = {
totalCount: 524,
rawResponse: {} as NetworkTopNFlowStrategyResponse['rawResponse'],
};
export const mockData: NetworkTopNFlowStrategyResponse = {
edges: [
{
node: {
@ -74,10 +81,5 @@ export const mockData: NetworkTopNFlowStrategyResponse = {
},
},
],
pageInfo: {
activePage: 1,
fakeTotalCount: 50,
showMorePagesIndicator: true,
},
rawResponse: {} as NetworkTopNFlowStrategyResponse['rawResponse'],
};

View file

@ -36,15 +36,10 @@ describe('useNetworkTopNFlow', () => {
result: {
edges: [],
totalCount: -1,
pageInfo: {
activePage: 0,
fakeTotalCount: 0,
showMorePagesIndicator: false,
},
},
search: mockSearch,
refetch: jest.fn(),
inspect: {},
inspect: { dsl: [], response: [] },
});
});
@ -77,7 +72,11 @@ describe('useNetworkTopNFlow', () => {
});
localProps.skip = true;
act(() => rerender());
expect(mockUseSearchStrategy).toHaveBeenCalledTimes(3);
expect(mockUseSearchStrategy.mock.calls[2][0].abort).toEqual(true);
// there are 2 calls inside the hook, 3 renders for each call
expect(mockUseSearchStrategy).toHaveBeenCalledTimes(6);
// last two calls are the ones that are aborted
expect(mockUseSearchStrategy.mock.calls[4][0].abort).toEqual(true);
expect(mockUseSearchStrategy.mock.calls[5][0].abort).toEqual(true);
});
});

View file

@ -13,13 +13,12 @@ import type { ESTermQuery } from '../../../../../common/typed_json';
import type { inputsModel } from '../../../../common/store';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { createFilter } from '../../../../common/containers/helpers';
import { generateTablePaginationOptions } from '../../../components/paginated_table/helpers';
import { getLimitedPaginationOptions } from '../../../components/paginated_table/helpers';
import type { networkModel } from '../../store';
import { networkSelectors } from '../../store';
import type {
FlowTargetSourceDest,
NetworkTopNFlowEdges,
PageInfoPaginated,
} from '../../../../../common/search_strategy';
import { NetworkQueries } from '../../../../../common/search_strategy';
import type { InspectResponse } from '../../../../types';
@ -33,13 +32,12 @@ export interface NetworkTopNFlowArgs {
inspect: InspectResponse;
isInspected: boolean;
loadPage: (newActivePage: number) => void;
pageInfo: PageInfoPaginated;
refetch: inputsModel.Refetch;
networkTopNFlow: NetworkTopNFlowEdges[];
totalCount: number;
}
interface UseNetworkTopNFlow {
interface UseNetworkTopNFlowProps {
flowTarget: FlowTargetSourceDest;
id: string;
ip?: string;
@ -61,7 +59,7 @@ export const useNetworkTopNFlow = ({
skip,
startDate,
type,
}: UseNetworkTopNFlow): [boolean, NetworkTopNFlowArgs] => {
}: UseNetworkTopNFlowProps): [boolean, NetworkTopNFlowArgs] => {
const getTopNFlowSelector = useMemo(() => networkSelectors.topNFlowSelector(), []);
const { activePage, limit, sort } = useDeepEqualSelector((state) =>
getTopNFlowSelector(state, type, flowTarget)
@ -70,16 +68,15 @@ export const useNetworkTopNFlow = ({
const [networkTopNFlowRequest, setTopNFlowRequest] =
useState<NetworkTopNFlowRequestOptionsInput | null>(null);
const wrappedLoadMore = useCallback(
const loadPage = useCallback(
(newActivePage: number) => {
setTopNFlowRequest((prevRequest) => {
if (!prevRequest) {
return prevRequest;
}
return {
...prevRequest,
pagination: generateTablePaginationOptions(newActivePage, limit),
pagination: getLimitedPaginationOptions(newActivePage, limit),
};
});
},
@ -87,49 +84,61 @@ export const useNetworkTopNFlow = ({
);
const {
loading,
loading: isLoadingData,
result: response,
search,
refetch,
refetch: refetchData,
inspect,
} = useSearchStrategy<NetworkQueries.topNFlow>({
factoryQueryType: NetworkQueries.topNFlow,
initialResult: {
edges: [],
totalCount: -1,
pageInfo: {
activePage: 0,
fakeTotalCount: 0,
showMorePagesIndicator: false,
},
},
initialResult: { edges: [] },
errorMessage: i18n.FAIL_NETWORK_TOP_N_FLOW,
abort: skip,
});
const {
loading: isLoadingTotalCount,
result: responseTotalCount,
search: searchTotalCount,
refetch: refetchTotalCount,
inspect: inspectTotalCount,
} = useSearchStrategy<NetworkQueries.topNFlowCount>({
factoryQueryType: NetworkQueries.topNFlowCount,
initialResult: { totalCount: -1 },
errorMessage: i18n.FAIL_NETWORK_TOP_N_FLOW,
abort: skip,
});
const isLoading = isLoadingData || isLoadingTotalCount;
const refetch = useCallback(() => {
refetchData();
refetchTotalCount();
}, [refetchData, refetchTotalCount]);
const networkTopNFlowResponse = useMemo(
() => ({
endDate,
networkTopNFlow: response.edges,
id,
inspect,
inspect: {
dsl: [...inspect.dsl, ...inspectTotalCount.dsl],
response: [...inspect.response, ...inspectTotalCount.response],
},
isInspected: false,
loadPage: wrappedLoadMore,
pageInfo: response.pageInfo,
loadPage,
refetch,
startDate,
totalCount: response.totalCount,
totalCount: responseTotalCount.totalCount,
}),
[
endDate,
id,
inspect,
inspectTotalCount,
refetch,
response.edges,
response.pageInfo,
response.totalCount,
responseTotalCount.totalCount,
startDate,
wrappedLoadMore,
loadPage,
]
);
@ -142,7 +151,7 @@ export const useNetworkTopNFlow = ({
filterQuery: createFilter(filterQuery),
flowTarget,
ip,
pagination: generateTablePaginationOptions(activePage, limit),
pagination: getLimitedPaginationOptions(activePage, limit),
timerange: {
interval: '12h',
from: startDate,
@ -160,8 +169,9 @@ export const useNetworkTopNFlow = ({
useEffect(() => {
if (!skip && networkTopNFlowRequest) {
search(networkTopNFlowRequest);
searchTotalCount(networkTopNFlowRequest);
}
}, [networkTopNFlowRequest, search, skip]);
}, [networkTopNFlowRequest, search, searchTotalCount, skip]);
return [loading, networkTopNFlowResponse];
return [isLoading, networkTopNFlowResponse];
};

View file

@ -6,7 +6,6 @@
*/
import React, { useEffect, useState } from 'react';
import { getOr } from 'lodash/fp';
import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table';
import { ID, useNetworkTopNFlow } from '../../containers/network_top_n_flow';
@ -34,25 +33,23 @@ export const IPsQueryTabBody = ({
useEffect(() => {
setQuerySkip(skip || !toggleStatus);
}, [skip, toggleStatus]);
const [
loading,
{ id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount },
] = useNetworkTopNFlow({
endDate,
flowTarget,
filterQuery,
id: queryId,
indexNames,
ip,
skip: querySkip,
startDate,
type,
});
const [loading, { id, inspect, isInspected, loadPage, networkTopNFlow, refetch, totalCount }] =
useNetworkTopNFlow({
endDate,
flowTarget,
filterQuery,
id: queryId,
indexNames,
ip,
skip: querySkip,
startDate,
type,
});
return (
<NetworkTopNFlowTableManage
data={networkTopNFlow}
fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)}
flowTargeted={flowTarget}
id={id}
inspect={inspect}
@ -62,7 +59,6 @@ export const IPsQueryTabBody = ({
refetch={refetch}
setQuery={setQuery}
setQuerySkip={setQuerySkip}
showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)}
totalCount={totalCount}
type={type}
/>

View file

@ -21,7 +21,7 @@ import { networkHttp } from './http';
import { networkOverview } from './overview';
import { networkTls } from './tls';
import { networkTopCountries } from './top_countries';
import { networkTopNFlow } from './top_n_flow';
import { networkTopNFlow, networkTopNFlowCount } from './top_n_flow';
import { networkUsers } from './users';
// TODO: add safer type for the strategy map
@ -33,6 +33,7 @@ export const networkFactory: Record<NetworkQueries | NetworkKpiQueries, any> = {
[NetworkQueries.overview]: networkOverview,
[NetworkQueries.tls]: networkTls,
[NetworkQueries.topCountries]: networkTopCountries,
[NetworkQueries.topNFlowCount]: networkTopNFlowCount,
[NetworkQueries.topNFlow]: networkTopNFlow,
[NetworkQueries.users]: networkUsers,
[NetworkKpiQueries.dns]: networkKpiDns,

View file

@ -6,9 +6,15 @@
*/
import type { IEsSearchResponse } from '@kbn/data-plugin/common';
import type { NetworkTopNFlowRequestOptions } from '../../../../../../../common/api/search_strategy';
import type {
NetworkTopNFlowCountRequestOptions,
NetworkTopNFlowRequestOptions,
} from '../../../../../../../common/api/search_strategy';
import type { NetworkTopNFlowStrategyResponse } from '../../../../../../../common/search_strategy';
import type {
NetworkTopNFlowCountStrategyResponse,
NetworkTopNFlowStrategyResponse,
} from '../../../../../../../common/search_strategy';
import {
Direction,
FlowTargetSourceDest,
@ -35,6 +41,23 @@ export const mockOptions: NetworkTopNFlowRequestOptions = {
timerange: { interval: '12h', from: '2020-09-13T10:16:46.870Z', to: '2020-09-14T10:16:46.870Z' },
};
export const mockCountOptions: NetworkTopNFlowCountRequestOptions = {
defaultIndex: [
'apm-*-transaction*',
'traces-apm*',
'auditbeat-*',
'endgame-*',
'filebeat-*',
'logs-*',
'packetbeat-*',
'winlogbeat-*',
],
factoryQueryType: NetworkQueries.topNFlowCount,
filterQuery: '{"bool":{"must":[],"filter":[{"match_all":{}}],"should":[],"must_not":[]}}',
flowTarget: FlowTargetSourceDest.source,
timerange: { interval: '12h', from: '2020-09-13T10:16:46.870Z', to: '2020-09-14T10:16:46.870Z' },
};
export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
isPartial: false,
isRunning: false,
@ -563,6 +586,20 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
},
],
},
},
},
total: 21,
loaded: 21,
};
export const mockCountStrategyResponse: IEsSearchResponse<unknown> = {
isPartial: false,
isRunning: false,
rawResponse: {
took: 191,
timed_out: false,
_shards: { total: 21, successful: 21, skipped: 0, failed: 0 },
hits: { max_score: 0, hits: [], total: 0 },
aggregations: {
top_n_flow_count: { value: 738 },
},
},
@ -837,7 +874,6 @@ export const formattedSearchStrategyResponse: NetworkTopNFlowStrategyResponse =
ignore_unavailable: true,
body: {
aggregations: {
top_n_flow_count: { cardinality: { field: 'source.ip' } },
source: {
terms: { field: 'source.ip', size: 10, order: { bytes_out: 'desc' } },
aggs: {
@ -920,7 +956,56 @@ export const formattedSearchStrategyResponse: NetworkTopNFlowStrategyResponse =
),
],
},
pageInfo: { activePage: 0, fakeTotalCount: 50, showMorePagesIndicator: true },
rawResponse: {} as NetworkTopNFlowStrategyResponse['rawResponse'],
};
export const formattedCountStrategyResponse: NetworkTopNFlowCountStrategyResponse = {
inspect: {
dsl: [
JSON.stringify(
{
allow_no_indices: true,
index: [
'apm-*-transaction*',
'traces-apm*',
'auditbeat-*',
'endgame-*',
'filebeat-*',
'logs-*',
'packetbeat-*',
'winlogbeat-*',
],
ignore_unavailable: true,
body: {
aggregations: {
top_n_flow_count: { cardinality: { field: 'source.ip' } },
},
query: {
bool: {
filter: [
{ bool: { must: [], filter: [{ match_all: {} }], should: [], must_not: [] } },
{
range: {
'@timestamp': {
gte: '2020-09-13T10:16:46.870Z',
lte: '2020-09-14T10:16:46.870Z',
format: 'strict_date_optional_time',
},
},
},
],
},
},
_source: false,
},
size: 0,
track_total_hits: false,
},
null,
2
),
],
},
totalCount: 738,
rawResponse: {} as NetworkTopNFlowStrategyResponse['rawResponse'],
};
@ -940,7 +1025,6 @@ export const expectedDsl = {
ignore_unavailable: true,
body: {
aggregations: {
top_n_flow_count: { cardinality: { field: 'source.ip' } },
source: {
terms: { field: 'source.ip', size: 10, order: { bytes_out: 'desc' } },
aggs: {
@ -1018,3 +1102,42 @@ export const expectedDsl = {
size: 0,
track_total_hits: false,
};
export const expectedCountDsl = {
allow_no_indices: true,
index: [
'apm-*-transaction*',
'traces-apm*',
'auditbeat-*',
'endgame-*',
'filebeat-*',
'logs-*',
'packetbeat-*',
'winlogbeat-*',
],
ignore_unavailable: true,
body: {
aggregations: {
top_n_flow_count: { cardinality: { field: 'source.ip' } },
},
query: {
bool: {
filter: [
{ bool: { must: [], filter: [{ match_all: {} }], should: [], must_not: [] } },
{
range: {
'@timestamp': {
gte: '2020-09-13T10:16:46.870Z',
lte: '2020-09-14T10:16:46.870Z',
format: 'strict_date_optional_time',
},
},
},
],
},
},
_source: false,
},
size: 0,
track_total_hits: false,
};

View file

@ -6,31 +6,61 @@
*/
import * as buildQuery from './query.top_n_flow_network.dsl';
import { networkTopNFlow } from '.';
import { networkTopNFlow, networkTopNFlowCount } from '.';
import {
mockOptions,
mockCountOptions,
mockSearchStrategyResponse,
formattedSearchStrategyResponse,
mockCountStrategyResponse,
formattedCountStrategyResponse,
} from './__mocks__';
describe('networkTopNFlow search strategy', () => {
const buildTopNFlowQuery = jest.spyOn(buildQuery, 'buildTopNFlowQuery');
describe('Network TopNFlow search strategy', () => {
describe('networkTopNFlow', () => {
const buildTopNFlowQuery = jest.spyOn(buildQuery, 'buildTopNFlowQuery');
afterEach(() => {
buildTopNFlowQuery.mockClear();
});
afterEach(() => {
buildTopNFlowQuery.mockClear();
});
describe('buildDsl', () => {
test('should build dsl query', () => {
networkTopNFlow.buildDsl(mockOptions);
expect(buildTopNFlowQuery).toHaveBeenCalledWith(mockOptions);
describe('buildDsl', () => {
test('should build dsl query', () => {
networkTopNFlow.buildDsl(mockOptions);
expect(buildTopNFlowQuery).toHaveBeenCalledWith(mockOptions);
});
});
describe('parse', () => {
test('should parse data correctly', async () => {
const result = await networkTopNFlow.parse(mockOptions, mockSearchStrategyResponse);
expect(result).toMatchObject(formattedSearchStrategyResponse);
});
});
});
describe('parse', () => {
test('should parse data correctly', async () => {
const result = await networkTopNFlow.parse(mockOptions, mockSearchStrategyResponse);
expect(result).toMatchObject(formattedSearchStrategyResponse);
describe('networkTopNFlowCount', () => {
const buildTopNFlowCountQuery = jest.spyOn(buildQuery, 'buildTopNFlowCountQuery');
afterEach(() => {
buildTopNFlowCountQuery.mockClear();
});
describe('buildDsl', () => {
test('should build dsl query', () => {
networkTopNFlowCount.buildDsl(mockCountOptions);
expect(buildTopNFlowCountQuery).toHaveBeenCalledWith(mockCountOptions);
});
});
describe('parse', () => {
test('should parse data correctly', async () => {
const result = await networkTopNFlowCount.parse(
mockCountOptions,
mockCountStrategyResponse
);
expect(result).toMatchObject(formattedCountStrategyResponse);
});
});
});
});

View file

@ -14,13 +14,14 @@ import type {
NetworkTopNFlowStrategyResponse,
NetworkQueries,
NetworkTopNFlowEdges,
NetworkTopNFlowCountStrategyResponse,
} from '../../../../../../common/search_strategy/security_solution/network';
import { inspectStringifyObject } from '../../../../../utils/build_query';
import type { SecuritySolutionFactory } from '../../types';
import { getTopNFlowEdges } from './helpers';
import { buildTopNFlowQuery } from './query.top_n_flow_network.dsl';
import { buildTopNFlowQuery, buildTopNFlowCountQuery } from './query.top_n_flow_network.dsl';
export const networkTopNFlow: SecuritySolutionFactory<NetworkQueries.topNFlow> = {
buildDsl: (options) => {
@ -33,26 +34,24 @@ export const networkTopNFlow: SecuritySolutionFactory<NetworkQueries.topNFlow> =
options,
response: IEsSearchResponse<unknown>
): Promise<NetworkTopNFlowStrategyResponse> => {
const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination;
const totalCount = getOr(0, 'aggregations.top_n_flow_count.value', response.rawResponse);
const { cursorStart, querySize } = options.pagination;
const networkTopNFlowEdges: NetworkTopNFlowEdges[] = getTopNFlowEdges(response, options);
const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount;
const edges = networkTopNFlowEdges.splice(cursorStart, querySize - cursorStart);
const inspect = {
dsl: [inspectStringifyObject(buildTopNFlowQuery(options))],
};
const showMorePagesIndicator = totalCount > fakeTotalCount;
return {
...response,
edges,
inspect,
pageInfo: {
activePage: activePage ?? 0,
fakeTotalCount,
showMorePagesIndicator,
},
totalCount,
};
const inspect = { dsl: [inspectStringifyObject(buildTopNFlowQuery(options))] };
return { ...response, inspect, edges };
},
};
export const networkTopNFlowCount: SecuritySolutionFactory<NetworkQueries.topNFlowCount> = {
buildDsl: (options) => buildTopNFlowCountQuery(options),
parse: async (
options,
response: IEsSearchResponse<unknown>
): Promise<NetworkTopNFlowCountStrategyResponse> => {
const totalCount = getOr(0, 'rawResponse.aggregations.top_n_flow_count.value', response);
const inspect = { dsl: [inspectStringifyObject(buildTopNFlowCountQuery(options))] };
return { ...response, inspect, totalCount };
},
};

View file

@ -5,11 +5,17 @@
* 2.0.
*/
import { buildTopNFlowQuery } from './query.top_n_flow_network.dsl';
import { mockOptions, expectedDsl } from './__mocks__';
import { buildTopNFlowCountQuery, buildTopNFlowQuery } from './query.top_n_flow_network.dsl';
import { mockOptions, mockCountOptions, expectedDsl, expectedCountDsl } from './__mocks__';
describe('buildTopNFlowQuery', () => {
test('build query from options correctly', () => {
expect(buildTopNFlowQuery(mockOptions)).toEqual(expectedDsl);
});
});
describe('buildTopNFlowCountQuery', () => {
test('build query from options correctly', () => {
expect(buildTopNFlowCountQuery(mockCountOptions)).toEqual(expectedCountDsl);
});
});

View file

@ -5,19 +5,30 @@
* 2.0.
*/
import type { NetworkTopNFlowRequestOptions } from '../../../../../../common/api/search_strategy';
import type {
AggregationsAggregationContainer,
AggregationsTopHitsAggregation,
Field,
QueryDslFieldAndFormat,
QueryDslQueryContainer,
} from '@elastic/elasticsearch/lib/api/types';
import type { ISearchRequestParams } from '@kbn/data-plugin/common';
import type {
NetworkTopNFlowCountRequestOptions,
NetworkTopNFlowRequestOptions,
} from '../../../../../../common/api/search_strategy';
import type { FlowTargetSourceDest } from '../../../../../../common/search_strategy';
import { createQueryFilterClauses } from '../../../../../utils/build_query';
import { getOppositeField } from '../helpers';
import { getQueryOrder } from './helpers';
const getCountAgg = (flowTarget: FlowTargetSourceDest) => ({
top_n_flow_count: {
cardinality: {
field: `${flowTarget}.ip`,
},
},
});
interface AggregationsAggregationWithFieldsContainer extends AggregationsAggregationContainer {
aggregations?: Record<string, AggregationsAggregationWithFieldsContainer>;
aggs?: Record<string, AggregationsAggregationWithFieldsContainer>;
top_hits?: AggregationsTopHitsAggregation & {
fields?: Array<QueryDslFieldAndFormat | Field>; // fields is missing in the official types but it is used in the query
};
}
export const buildTopNFlowQuery = ({
defaultIndex,
@ -25,46 +36,19 @@ export const buildTopNFlowQuery = ({
flowTarget,
sort,
pagination,
timerange: { from, to },
timerange,
ip,
}: NetworkTopNFlowRequestOptions) => {
}: NetworkTopNFlowRequestOptions): ISearchRequestParams => {
const querySize = pagination?.querySize ?? 10;
const filter = [
...createQueryFilterClauses(filterQuery),
{
range: {
'@timestamp': { gte: from, lte: to, format: 'strict_date_optional_time' },
},
},
];
const query = getQuery({ filterQuery, flowTarget, timerange, ip });
const dslQuery = {
allow_no_indices: true,
index: defaultIndex,
ignore_unavailable: true,
body: {
aggregations: {
...getCountAgg(flowTarget),
...getFlowTargetAggs(sort, flowTarget, querySize),
},
query: {
bool: ip
? {
filter,
should: [
{
term: {
[`${getOppositeField(flowTarget)}.ip`]: ip,
},
},
],
minimum_should_match: 1,
}
: {
filter,
},
},
aggregations: getFlowTargetAggs(sort, flowTarget, querySize),
query,
_source: false,
fields: [
{
@ -79,11 +63,61 @@ export const buildTopNFlowQuery = ({
return dslQuery;
};
export const buildTopNFlowCountQuery = ({
defaultIndex,
filterQuery,
flowTarget,
timerange,
ip,
}: NetworkTopNFlowCountRequestOptions): ISearchRequestParams => {
const query = getQuery({ filterQuery, flowTarget, timerange, ip });
const dslQuery = {
allow_no_indices: true,
index: defaultIndex,
ignore_unavailable: true,
body: { aggregations: getCountAgg(flowTarget), query, _source: false },
size: 0,
track_total_hits: false,
};
return dslQuery;
};
// creates the dsl bool query with the filters
const getQuery = ({
filterQuery,
flowTarget,
timerange: { from, to },
ip,
}: Pick<
NetworkTopNFlowRequestOptions,
'filterQuery' | 'flowTarget' | 'timerange' | 'ip'
>): QueryDslQueryContainer => ({
bool: {
filter: [...createQueryFilterClauses(filterQuery), getTimeRangeFilter(from, to)],
...(ip && {
should: [{ term: { [`${getOppositeField(flowTarget)}.ip`]: ip } }],
minimum_should_match: 1,
}),
},
});
const getTimeRangeFilter = (from: string, to: string) => ({
range: {
'@timestamp': { gte: from, lte: to, format: 'strict_date_optional_time' },
},
});
const getCountAgg = (
flowTarget: FlowTargetSourceDest
): Record<string, AggregationsAggregationContainer> => ({
top_n_flow_count: { cardinality: { field: `${flowTarget}.ip` } },
});
const getFlowTargetAggs = (
sort: NetworkTopNFlowRequestOptions['sort'],
flowTarget: FlowTargetSourceDest,
querySize: number
) => ({
): Record<string, AggregationsAggregationWithFieldsContainer> => ({
[flowTarget]: {
terms: {
field: `${flowTarget}.ip`,

View file

@ -36,7 +36,27 @@ export default function ({ getService }: FtrProviderContext) {
const FROM = '2019-02-09T01:57:24.870Z';
const TO = '2019-02-12T01:57:24.870Z';
it('Make sure that we get Source NetworkTopNFlow data with bytes_in descending sort', async () => {
it('should get Source NetworkTopNFlowCount total count', async () => {
const networkTopNFlow = await bsearch.send<NetworkTopNFlowStrategyResponse>({
supertest,
options: {
defaultIndex: ['filebeat-*'],
factoryQueryType: NetworkQueries.topNFlowCount,
flowTarget: FlowTargetSourceDest.source,
timerange: {
interval: '12h',
to: TO,
from: FROM,
},
inspect: false,
},
strategy: 'securitySolutionSearchStrategy',
});
expect(networkTopNFlow.totalCount).to.be(121);
});
it('should get Source NetworkTopNFlow data with bytes_in descending sort', async () => {
const networkTopNFlow = await bsearch.send<NetworkTopNFlowStrategyResponse>({
supertest,
options: {
@ -47,7 +67,7 @@ export default function ({ getService }: FtrProviderContext) {
pagination: {
activePage: 0,
cursorStart: 0,
fakePossibleCount: 50,
fakePossibleCount: 0,
querySize: 10,
},
timerange: {
@ -61,7 +81,6 @@ export default function ({ getService }: FtrProviderContext) {
});
expect(networkTopNFlow.edges.length).to.be(EDGE_LENGTH);
expect(networkTopNFlow.totalCount).to.be(121);
expect(
networkTopNFlow.edges.map((i: NetworkTopNFlowEdges) => i.node.source!.ip).join(',')
).to.be(
@ -70,15 +89,14 @@ export default function ({ getService }: FtrProviderContext) {
expect(networkTopNFlow.edges[0].node.destination).to.be(undefined);
expect(networkTopNFlow.edges[0].node.source!.flows).to.be(498);
expect(networkTopNFlow.edges[0].node.source!.destination_ips).to.be(132);
expect(networkTopNFlow.pageInfo.fakeTotalCount).to.equal(50);
});
it('Make sure that we get Source NetworkTopNFlow data with bytes_in ascending sort ', async () => {
it('should get Source NetworkTopNFlow data with bytes_in ascending sort ', async () => {
const networkTopNFlow = await bsearch.send<NetworkTopNFlowStrategyResponse>({
supertest,
options: {
defaultIndex: ['filebeat-*'],
factoryQueryType: 'topNFlow',
factoryQueryType: NetworkQueries.topNFlow,
filterQuery:
'{"bool":{"must":[],"filter":[{"match_all":{}}],"should":[],"must_not":[]}}',
flowTarget: FlowTargetSourceDest.source,
@ -86,7 +104,7 @@ export default function ({ getService }: FtrProviderContext) {
pagination: {
activePage: 0,
cursorStart: 0,
fakePossibleCount: 50,
fakePossibleCount: 0,
querySize: 10,
},
timerange: {
@ -100,7 +118,6 @@ export default function ({ getService }: FtrProviderContext) {
});
expect(networkTopNFlow.edges.length).to.be(EDGE_LENGTH);
expect(networkTopNFlow.totalCount).to.be(121);
expect(
networkTopNFlow.edges.map((i: NetworkTopNFlowEdges) => i.node.source!.ip).join(',')
).to.be(
@ -109,10 +126,9 @@ export default function ({ getService }: FtrProviderContext) {
expect(networkTopNFlow.edges[0].node.destination).to.be(undefined);
expect(networkTopNFlow.edges[0].node.source!.flows).to.be(12);
expect(networkTopNFlow.edges[0].node.source!.destination_ips).to.be(1);
expect(networkTopNFlow.pageInfo.fakeTotalCount).to.equal(50);
});
it('Make sure that we get Destination NetworkTopNFlow data', async () => {
it('should get Destination NetworkTopNFlow data', async () => {
const networkTopNFlow = await bsearch.send<NetworkTopNFlowStrategyResponse>({
supertest,
options: {
@ -125,7 +141,7 @@ export default function ({ getService }: FtrProviderContext) {
pagination: {
activePage: 0,
cursorStart: 0,
fakePossibleCount: 50,
fakePossibleCount: 0,
querySize: 10,
},
timerange: {
@ -139,14 +155,12 @@ export default function ({ getService }: FtrProviderContext) {
});
expect(networkTopNFlow.edges.length).to.be(EDGE_LENGTH);
expect(networkTopNFlow.totalCount).to.be(154);
expect(networkTopNFlow.edges[0].node.destination!.flows).to.be(19);
expect(networkTopNFlow.edges[0].node.destination!.source_ips).to.be(1);
expect(networkTopNFlow.edges[0].node.source).to.be(undefined);
expect(networkTopNFlow.pageInfo.fakeTotalCount).to.equal(50);
});
it('Make sure that pagination is working in NetworkTopNFlow query', async () => {
it('should paginate NetworkTopNFlow query', async () => {
const networkTopNFlow = await bsearch.send<NetworkTopNFlowStrategyResponse>({
supertest,
options: {
@ -159,7 +173,7 @@ export default function ({ getService }: FtrProviderContext) {
pagination: {
activePage: 1,
cursorStart: 10,
fakePossibleCount: 50,
fakePossibleCount: 0,
querySize: 20,
},
timerange: {
@ -173,7 +187,6 @@ export default function ({ getService }: FtrProviderContext) {
});
expect(networkTopNFlow.edges.length).to.be(EDGE_LENGTH);
expect(networkTopNFlow.totalCount).to.be(121);
expect(networkTopNFlow.edges[0].node.source!.ip).to.be('8.248.223.246');
});
});