[Secutiy Solution] Timeline kpis (#89210)

* Stub kpi component

* search strategy scheleton timeline KPI

* search strategy scheleton timeline KPI

* Add timeline kpis component and search strategy container

* Use getEmptyValue in timeline kpis

* Prevent request from being made for blank timeline properly

* Add kpi search strategy api integration test

* Add jest tests for timeline kpis

* Clear mocks in afterAll

* Decouple some tests from EUI structure

* Combine some selector calls, change types to be more appropriate

* Simplify hook logic

* Set loading and response on blank timeline

* Only render kpi component when query is active tab

* Use TimelineTabs enum for query tab string

Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Kevin Qualters 2021-02-02 18:30:45 -05:00 committed by GitHub
parent 980112de1e
commit 3da4c6bb2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 631 additions and 34 deletions

View file

@ -11,5 +11,6 @@ export * from './last_event_time';
export enum TimelineEventsQueries {
all = 'eventsAll',
details = 'eventsDetails',
kpi = 'eventsKpi',
lastEventTime = 'eventsLastEventTime',
}

View file

@ -25,6 +25,15 @@ export interface TimelineEventsLastEventTimeStrategyResponse extends IEsSearchRe
inspect?: Maybe<Inspect>;
}
export interface TimelineKpiStrategyResponse extends IEsSearchResponse {
destinationIpCount: number;
inspect?: Maybe<Inspect>;
hostCount: number;
processCount: number;
sourceIpCount: number;
userCount: number;
}
export interface TimelineEventsLastEventTimeRequestOptions
extends Omit<TimelineRequestBasicOptions, 'filterQuery' | 'timerange'> {
indexKey: LastEventIndexKey;

View file

@ -13,6 +13,7 @@ import {
TimelineEventsDetailsStrategyResponse,
TimelineEventsLastEventTimeRequestOptions,
TimelineEventsLastEventTimeStrategyResponse,
TimelineKpiStrategyResponse,
} from './events';
import { DocValueFields, PaginationInputPaginated, TimerangeInput, SortField } from '../common';
@ -44,6 +45,8 @@ export type TimelineStrategyResponseType<
? TimelineEventsAllStrategyResponse
: T extends TimelineEventsQueries.details
? TimelineEventsDetailsStrategyResponse
: T extends TimelineEventsQueries.kpi
? TimelineKpiStrategyResponse
: T extends TimelineEventsQueries.lastEventTime
? TimelineEventsLastEventTimeStrategyResponse
: never;
@ -54,6 +57,8 @@ export type TimelineStrategyRequestType<
? TimelineEventsAllRequestOptions
: T extends TimelineEventsQueries.details
? TimelineEventsDetailsRequestOptions
: T extends TimelineEventsQueries.kpi
? TimelineRequestBasicOptions
: T extends TimelineEventsQueries.lastEventTime
? TimelineEventsLastEventTimeRequestOptions
: never;

View file

@ -0,0 +1,117 @@
/*
* 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 React from 'react';
import { useKibana } from '../../../../common/lib/kibana';
import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock';
import { useTimelineKpis } from '../../../containers/kpis';
import { FlyoutHeader } from '.';
import { useSourcererScope } from '../../../../common/containers/sourcerer';
import { mockBrowserFields, mockDocValueFields } from '../../../../common/containers/source/mock';
import { useMountAppended } from '../../../../common/utils/use_mount_appended';
import { getEmptyValue } from '../../../../common/components/empty_value';
const mockUseSourcererScope: jest.Mock = useSourcererScope as jest.Mock;
jest.mock('../../../../common/containers/sourcerer');
const mockUseTimelineKpis: jest.Mock = useTimelineKpis as jest.Mock;
jest.mock('../../../containers/kpis', () => ({
useTimelineKpis: jest.fn(),
}));
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
jest.mock('../../../../common/lib/kibana');
const mockUseTimelineKpiResponse = {
processCount: 1,
userCount: 1,
sourceIpCount: 1,
hostCount: 1,
destinationIpCount: 1,
};
const defaultMocks = {
browserFields: mockBrowserFields,
docValueFields: mockDocValueFields,
indexPattern: mockIndexPattern,
loading: false,
selectedPatterns: mockIndexNames,
};
describe('Timeline KPIs', () => {
const mount = useMountAppended();
beforeEach(() => {
// Mocking these services is required for the header component to render.
mockUseSourcererScope.mockImplementation(() => defaultMocks);
useKibanaMock().services.application.capabilities = {
navLinks: {},
management: {},
catalogue: {},
actions: { show: true, crud: true },
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('when the data is not loading and the response contains data', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]);
});
it('renders the component, labels and values succesfully', async () => {
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={'timeline-1'} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true);
// label
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('Processes')
);
// value
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('1')
);
});
});
describe('when the data is loading', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]);
});
it('renders a loading indicator for values', async () => {
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={'timeline-1'} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('--')
);
});
});
describe('when the response is null and timeline is blank', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([false, null]);
});
it('renders labels and the default empty string', async () => {
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={'timeline-1'} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('Processes')
);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining(getEmptyValue())
);
});
});
});

View file

@ -15,25 +15,42 @@ import {
} from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { isEmpty, get, pick } from 'lodash/fp';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { FormattedRelative } from '@kbn/i18n/react';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { TimelineStatus, TimelineTabs, TimelineType } from '../../../../../common/types/timeline';
import {
TimelineStatus,
TimelineTabs,
TimelineType,
TimelineId,
} from '../../../../../common/types/timeline';
import { State } from '../../../../common/store';
import { timelineActions, timelineSelectors } from '../../../store/timeline';
import { timelineDefaults } from '../../../../timelines/store/timeline/defaults';
import { AddToFavoritesButton } from '../../timeline/properties/helpers';
import { TimerangeInput } from '../../../../../common/search_strategy';
import { AddToCaseButton } from '../add_to_case_button';
import { AddTimelineButton } from '../add_timeline_button';
import { SaveTimelineButton } from '../../timeline/header/save_timeline_button';
import { useKibana } from '../../../../common/lib/kibana';
import { InspectButton } from '../../../../common/components/inspect';
import { useTimelineKpis } from '../../../containers/kpis';
import { esQuery } from '../../../../../../../../src/plugins/data/public';
import { useSourcererScope } from '../../../../common/containers/sourcerer';
import { TimelineModel } from '../../../../timelines/store/timeline/model';
import {
startSelector,
endSelector,
} from '../../../../common/components/super_date_picker/selectors';
import { combineQueries, focusActiveTimelineButton } from '../../timeline/helpers';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { ActiveTimelines } from './active_timelines';
import * as i18n from './translations';
import * as commonI18n from '../../timeline/properties/translations';
import { getTimelineStatusByIdSelector } from './selectors';
import { focusActiveTimelineButton } from '../../timeline/helpers';
import { TimelineKPIs } from './kpis';
// to hide side borders
const StyledPanel = styled(EuiPanel)`
@ -227,38 +244,106 @@ const TimelineStatusInfoComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }
const TimelineStatusInfo = React.memo(TimelineStatusInfoComponent);
const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => (
<StyledTimelineHeader alignItems="center">
<EuiFlexItem>
<EuiFlexGroup data-test-subj="properties-left" direction="column" gutterSize="none">
<RowFlexItem>
<TimelineName timelineId={timelineId} />
<SaveTimelineButton timelineId={timelineId} initialFocus="title" />
</RowFlexItem>
<RowFlexItem>
<TimelineDescription timelineId={timelineId} />
<SaveTimelineButton timelineId={timelineId} initialFocus="description" />
</RowFlexItem>
<EuiFlexItem>
<TimelineStatusInfo timelineId={timelineId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => {
const { selectedPatterns, indexPattern, docValueFields, browserFields } = useSourcererScope(
SourcererScopeName.timeline
);
const getStartSelector = useMemo(() => startSelector(), []);
const getEndSelector = useMemo(() => endSelector(), []);
const isActive = useMemo(() => timelineId === TimelineId.active, [timelineId]);
const timerange: TimerangeInput = useDeepEqualSelector((state) => {
if (isActive) {
return {
from: getStartSelector(state.inputs.timeline),
to: getEndSelector(state.inputs.timeline),
interval: '',
};
} else {
return {
from: getStartSelector(state.inputs.global),
to: getEndSelector(state.inputs.global),
interval: '',
};
}
});
const { uiSettings } = useKibana().services;
const esQueryConfig = useMemo(() => esQuery.getEsQueryConfig(uiSettings), [uiSettings]);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const timeline: TimelineModel = useSelector(
(state: State) => getTimeline(state, timelineId) ?? timelineDefaults
);
const { dataProviders, filters, timelineType, kqlMode, activeTab } = timeline;
const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterQuerySelector(), []);
const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId)!);
<EuiFlexItem grow={1}>{/* KPIs PLACEHOLDER */}</EuiFlexItem>
const kqlQueryExpression =
isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template'
? ' '
: kqlQueryTimeline;
const kqlQuery = useMemo(() => ({ query: kqlQueryExpression, language: 'kuery' }), [
kqlQueryExpression,
]);
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<AddToFavoritesButton timelineId={timelineId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddToCaseButton timelineId={timelineId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</StyledTimelineHeader>
);
const isBlankTimeline: boolean = useMemo(
() => isEmpty(dataProviders) && isEmpty(filters) && isEmpty(kqlQuery.query),
[dataProviders, filters, kqlQuery]
);
const combinedQueries = useMemo(
() =>
combineQueries({
config: esQueryConfig,
dataProviders,
indexPattern,
browserFields,
filters: filters ? filters : [],
kqlQuery,
kqlMode,
}),
[browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQuery]
);
const [loading, kpis] = useTimelineKpis({
defaultIndex: selectedPatterns,
docValueFields,
timerange,
isBlankTimeline,
filterQuery: combinedQueries?.filterQuery ?? '',
});
return (
<StyledTimelineHeader alignItems="center">
<EuiFlexItem>
<EuiFlexGroup data-test-subj="properties-left" direction="column" gutterSize="none">
<RowFlexItem>
<TimelineName timelineId={timelineId} />
<SaveTimelineButton timelineId={timelineId} initialFocus="title" />
</RowFlexItem>
<RowFlexItem>
<TimelineDescription timelineId={timelineId} />
<SaveTimelineButton timelineId={timelineId} initialFocus="description" />
</RowFlexItem>
<EuiFlexItem>
<TimelineStatusInfo timelineId={timelineId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={1}>
{activeTab === TimelineTabs.query ? <TimelineKPIs kpis={kpis} isLoading={loading} /> : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<AddToFavoritesButton timelineId={timelineId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddToCaseButton timelineId={timelineId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</StyledTimelineHeader>
);
};
FlyoutHeaderComponent.displayName = 'FlyoutHeaderComponent';

View file

@ -0,0 +1,68 @@
/*
* 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 React from 'react';
import { EuiStat, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { TimelineKpiStrategyResponse } from '../../../../../common/search_strategy';
import { getEmptyValue } from '../../../../common/components/empty_value';
import * as i18n from './translations';
export const TimelineKPIs = React.memo(
({ kpis, isLoading }: { kpis: TimelineKpiStrategyResponse | null; isLoading: boolean }) => {
return (
<EuiFlexGroup wrap data-test-subj="siem-timeline-kpis">
<EuiFlexItem>
<EuiStat
data-test-subj="siem-timeline-process-kpi"
title={kpis === null ? getEmptyValue() : kpis.processCount}
description={i18n.PROCESS_KPI_TITLE}
titleSize="s"
isLoading={isLoading}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiStat
data-test-subj="siem-timeline-user-kpi"
title={kpis === null ? getEmptyValue() : kpis.userCount}
description={i18n.USER_KPI_TITLE}
titleSize="s"
isLoading={isLoading}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiStat
data-test-subj="siem-timeline-host-kpi"
title={kpis === null ? getEmptyValue() : kpis.hostCount}
description={i18n.HOST_KPI_TITLE}
titleSize="s"
isLoading={isLoading}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiStat
data-test-subj="siem-timeline-source-ip-kpi"
title={kpis === null ? getEmptyValue() : kpis.sourceIpCount}
description={i18n.SOURCE_IP_KPI_TITLE}
titleSize="s"
isLoading={isLoading}
/>
</EuiFlexItem>
<EuiFlexItem style={{ minWidth: 100 }}>
<EuiStat
data-test-subj="siem-timeline-destination-ip-kpi"
title={kpis === null ? getEmptyValue() : kpis.destinationIpCount}
description={i18n.DESTINATION_IP_KPI_TITLE}
titleSize="s"
isLoading={isLoading}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
TimelineKPIs.displayName = 'TimelineKPIs';

View file

@ -31,6 +31,35 @@ export const INSPECT_TIMELINE_TITLE = i18n.translate(
}
);
export const PROCESS_KPI_TITLE = i18n.translate(
'xpack.securitySolution.timeline.kpis.processKpiTitle',
{
defaultMessage: 'Processes',
}
);
export const HOST_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.hostKpiTitle', {
defaultMessage: 'Hosts',
});
export const SOURCE_IP_KPI_TITLE = i18n.translate(
'xpack.securitySolution.timeline.kpis.sourceIpKpiTitle',
{
defaultMessage: 'Source IPs',
}
);
export const DESTINATION_IP_KPI_TITLE = i18n.translate(
'xpack.securitySolution.timeline.kpis.destinationKpiTitle',
{
defaultMessage: 'Destination IPs',
}
);
export const USER_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.userKpiTitle', {
defaultMessage: 'Users',
});
export const TIMELINE_TOGGLE_BUTTON_ARIA_LABEL = ({
isOpen,
title,

View file

@ -0,0 +1,129 @@
/*
* 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 { noop } from 'lodash/fp';
import { useCallback, useEffect, useRef, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import { inputsModel } from '../../../common/store';
import { useKibana } from '../../../common/lib/kibana';
import {
DocValueFields,
TimelineEventsQueries,
TimelineRequestBasicOptions,
TimelineKpiStrategyResponse,
TimerangeInput,
} from '../../../../common/search_strategy';
import { ESQuery } from '../../../../common/typed_json';
import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/public';
import { AbortError } from '../../../../../../../src/plugins/kibana_utils/common';
export interface UseTimelineKpiProps {
timerange: TimerangeInput;
filterQuery?: ESQuery | string | undefined;
defaultIndex: string[];
docValueFields?: DocValueFields[];
isBlankTimeline: boolean;
}
export const useTimelineKpis = ({
timerange,
filterQuery,
docValueFields,
defaultIndex,
isBlankTimeline,
}: UseTimelineKpiProps): [boolean, TimelineKpiStrategyResponse | null] => {
const { data, notifications } = useKibana().services;
const refetch = useRef<inputsModel.Refetch>(noop);
const abortCtrl = useRef(new AbortController());
const didCancel = useRef(false);
const [loading, setLoading] = useState(false);
const [timelineKpiRequest, setTimelineKpiRequest] = useState<TimelineRequestBasicOptions | null>(
null
);
const [
timelineKpiResponse,
setTimelineKpiResponse,
] = useState<TimelineKpiStrategyResponse | null>(null);
const timelineKpiSearch = useCallback(
(request: TimelineRequestBasicOptions | null) => {
if (request == null) {
return;
}
didCancel.current = false;
const asyncSearch = async () => {
abortCtrl.current = new AbortController();
setLoading(true);
const searchSubscription$ = data.search
.search<TimelineRequestBasicOptions, TimelineKpiStrategyResponse>(request, {
strategy: 'securitySolutionTimelineSearchStrategy',
abortSignal: abortCtrl.current.signal,
})
.subscribe({
next: (response) => {
if (isCompleteResponse(response)) {
if (!didCancel.current) {
setLoading(false);
setTimelineKpiResponse(response);
}
searchSubscription$.unsubscribe();
} else if (isErrorResponse(response)) {
if (!didCancel.current) {
setLoading(false);
}
notifications.toasts.addWarning('An error has occurred');
searchSubscription$.unsubscribe();
}
},
error: (msg) => {
if (!didCancel.current) {
setLoading(false);
}
if (!(msg instanceof AbortError)) {
notifications.toasts.addDanger('Failed to load KPIs');
}
},
});
};
abortCtrl.current.abort();
asyncSearch();
refetch.current = asyncSearch;
},
[data.search, notifications.toasts]
);
useEffect(() => {
setTimelineKpiRequest((prevRequest) => {
const myRequest = {
...(prevRequest ?? {}),
docValueFields,
defaultIndex,
timerange,
filterQuery,
factoryQueryType: TimelineEventsQueries.kpi,
};
if (!deepEqual(prevRequest, myRequest)) {
return myRequest;
}
return prevRequest;
});
}, [docValueFields, defaultIndex, timerange, filterQuery]);
useEffect(() => {
if (!isBlankTimeline) {
timelineKpiSearch(timelineKpiRequest);
} else {
setLoading(false);
setTimelineKpiResponse(null);
}
return () => {
didCancel.current = true;
abortCtrl.current.abort();
};
}, [isBlankTimeline, timelineKpiRequest, timelineKpiSearch]);
return [loading, timelineKpiResponse];
};

View file

@ -12,6 +12,7 @@ import {
import { SecuritySolutionTimelineFactory } from '../types';
import { timelineEventsAll } from './all';
import { timelineEventsDetails } from './details';
import { timelineKpi } from './kpi';
import { timelineEventsLastEventTime } from './last_event_time';
export const timelineEventsFactory: Record<
@ -20,5 +21,6 @@ export const timelineEventsFactory: Record<
> = {
[TimelineEventsQueries.all]: timelineEventsAll,
[TimelineEventsQueries.details]: timelineEventsDetails,
[TimelineEventsQueries.kpi]: timelineKpi,
[TimelineEventsQueries.lastEventTime]: timelineEventsLastEventTime,
};

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getOr } from 'lodash/fp';
import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common';
import {
TimelineEventsQueries,
TimelineRequestBasicOptions,
TimelineKpiStrategyResponse,
} from '../../../../../../common/search_strategy/timeline';
import { inspectStringifyObject } from '../../../../../utils/build_query';
import { SecuritySolutionTimelineFactory } from '../../types';
import { buildTimelineKpiQuery } from './query.kpi.dsl';
export const timelineKpi: SecuritySolutionTimelineFactory<TimelineEventsQueries.kpi> = {
buildDsl: (options: TimelineRequestBasicOptions) => buildTimelineKpiQuery(options),
parse: async (
options: TimelineRequestBasicOptions,
response: IEsSearchResponse<unknown>
): Promise<TimelineKpiStrategyResponse> => {
const inspect = {
dsl: [inspectStringifyObject(buildTimelineKpiQuery(options))],
};
return {
...response,
destinationIpCount: getOr(0, 'aggregations.destinationIpCount.value', response.rawResponse),
inspect,
hostCount: getOr(0, 'aggregations.hostCount.value', response.rawResponse),
processCount: getOr(0, 'aggregations.processCount.value', response.rawResponse),
sourceIpCount: getOr(0, 'aggregations.sourceIpCount.value', response.rawResponse),
userCount: getOr(0, 'aggregations.userCount.value', response.rawResponse),
};
},
};

View file

@ -0,0 +1,86 @@
/*
* 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 { isEmpty } from 'lodash/fp';
import {
TimerangeFilter,
TimerangeInput,
TimelineRequestBasicOptions,
} from '../../../../../../common/search_strategy';
import { createQueryFilterClauses } from '../../../../../utils/build_query';
export const buildTimelineKpiQuery = ({
defaultIndex,
filterQuery,
timerange,
}: TimelineRequestBasicOptions) => {
const filterClause = [...createQueryFilterClauses(filterQuery)];
const getTimerangeFilter = (timerangeOption: TimerangeInput | undefined): TimerangeFilter[] => {
if (timerangeOption) {
const { to, from } = timerangeOption;
return !isEmpty(to) && !isEmpty(from)
? [
{
range: {
'@timestamp': {
gte: from,
lte: to,
format: 'strict_date_optional_time',
},
},
},
]
: [];
}
return [];
};
const filter = [...filterClause, ...getTimerangeFilter(timerange), { match_all: {} }];
const dslQuery = {
allowNoIndices: true,
index: defaultIndex,
ignoreUnavailable: true,
body: {
aggs: {
userCount: {
cardinality: {
field: 'user.id',
},
},
destinationIpCount: {
cardinality: {
field: 'destination.ip',
},
},
hostCount: {
cardinality: {
field: 'host.id',
},
},
processCount: {
cardinality: {
field: 'process.entity_id',
},
},
sourceIpCount: {
cardinality: {
field: 'source.ip',
},
},
},
query: {
bool: {
filter,
},
},
track_total_hits: true,
},
};
return dslQuery;
};

View file

@ -558,6 +558,14 @@ const EXPECTED_DATA = [
},
];
const EXPECTED_KPI_COUNTS = {
destinationIpCount: 154,
hostCount: 1,
processCount: 0,
sourceIpCount: 121,
userCount: 0,
};
export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const supertest = getService('supertest');
@ -587,5 +595,24 @@ export default function ({ getService }: FtrProviderContext) {
})
).to.eql(sortBy(EXPECTED_DATA, 'name'));
});
it('Make sure that we get kpi data', async () => {
const {
body: { destinationIpCount, hostCount, processCount, sourceIpCount, userCount },
} = await supertest
.post('/internal/search/securitySolutionTimelineSearchStrategy/')
.set('kbn-xsrf', 'true')
.send({
factoryQueryType: TimelineEventsQueries.kpi,
docValueFields: [],
indexName: INDEX_NAME,
inspect: false,
eventId: ID,
})
.expect(200);
expect({ destinationIpCount, hostCount, processCount, sourceIpCount, userCount }).to.eql(
EXPECTED_KPI_COUNTS
);
});
});
}