[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:
parent
980112de1e
commit
3da4c6bb2c
|
@ -11,5 +11,6 @@ export * from './last_event_time';
|
|||
export enum TimelineEventsQueries {
|
||||
all = 'eventsAll',
|
||||
details = 'eventsDetails',
|
||||
kpi = 'eventsKpi',
|
||||
lastEventTime = 'eventsLastEventTime',
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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())
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,7 +244,72 @@ const TimelineStatusInfoComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }
|
|||
|
||||
const TimelineStatusInfo = React.memo(TimelineStatusInfoComponent);
|
||||
|
||||
const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => (
|
||||
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)!);
|
||||
|
||||
const kqlQueryExpression =
|
||||
isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template'
|
||||
? ' '
|
||||
: kqlQueryTimeline;
|
||||
const kqlQuery = useMemo(() => ({ query: kqlQueryExpression, language: 'kuery' }), [
|
||||
kqlQueryExpression,
|
||||
]);
|
||||
|
||||
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">
|
||||
|
@ -245,7 +327,9 @@ const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => (
|
|||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={1}>{/* KPIs PLACEHOLDER */}</EuiFlexItem>
|
||||
<EuiFlexItem grow={1}>
|
||||
{activeTab === TimelineTabs.query ? <TimelineKPIs kpis={kpis} isLoading={loading} /> : null}
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
|
@ -259,6 +343,7 @@ const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => (
|
|||
</EuiFlexItem>
|
||||
</StyledTimelineHeader>
|
||||
);
|
||||
};
|
||||
|
||||
FlyoutHeaderComponent.displayName = 'FlyoutHeaderComponent';
|
||||
|
||||
|
|
|
@ -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';
|
|
@ -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,
|
||||
|
|
|
@ -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];
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue