[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 {
|
export enum TimelineEventsQueries {
|
||||||
all = 'eventsAll',
|
all = 'eventsAll',
|
||||||
details = 'eventsDetails',
|
details = 'eventsDetails',
|
||||||
|
kpi = 'eventsKpi',
|
||||||
lastEventTime = 'eventsLastEventTime',
|
lastEventTime = 'eventsLastEventTime',
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,15 @@ export interface TimelineEventsLastEventTimeStrategyResponse extends IEsSearchRe
|
||||||
inspect?: Maybe<Inspect>;
|
inspect?: Maybe<Inspect>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TimelineKpiStrategyResponse extends IEsSearchResponse {
|
||||||
|
destinationIpCount: number;
|
||||||
|
inspect?: Maybe<Inspect>;
|
||||||
|
hostCount: number;
|
||||||
|
processCount: number;
|
||||||
|
sourceIpCount: number;
|
||||||
|
userCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TimelineEventsLastEventTimeRequestOptions
|
export interface TimelineEventsLastEventTimeRequestOptions
|
||||||
extends Omit<TimelineRequestBasicOptions, 'filterQuery' | 'timerange'> {
|
extends Omit<TimelineRequestBasicOptions, 'filterQuery' | 'timerange'> {
|
||||||
indexKey: LastEventIndexKey;
|
indexKey: LastEventIndexKey;
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
TimelineEventsDetailsStrategyResponse,
|
TimelineEventsDetailsStrategyResponse,
|
||||||
TimelineEventsLastEventTimeRequestOptions,
|
TimelineEventsLastEventTimeRequestOptions,
|
||||||
TimelineEventsLastEventTimeStrategyResponse,
|
TimelineEventsLastEventTimeStrategyResponse,
|
||||||
|
TimelineKpiStrategyResponse,
|
||||||
} from './events';
|
} from './events';
|
||||||
import { DocValueFields, PaginationInputPaginated, TimerangeInput, SortField } from '../common';
|
import { DocValueFields, PaginationInputPaginated, TimerangeInput, SortField } from '../common';
|
||||||
|
|
||||||
|
@ -44,6 +45,8 @@ export type TimelineStrategyResponseType<
|
||||||
? TimelineEventsAllStrategyResponse
|
? TimelineEventsAllStrategyResponse
|
||||||
: T extends TimelineEventsQueries.details
|
: T extends TimelineEventsQueries.details
|
||||||
? TimelineEventsDetailsStrategyResponse
|
? TimelineEventsDetailsStrategyResponse
|
||||||
|
: T extends TimelineEventsQueries.kpi
|
||||||
|
? TimelineKpiStrategyResponse
|
||||||
: T extends TimelineEventsQueries.lastEventTime
|
: T extends TimelineEventsQueries.lastEventTime
|
||||||
? TimelineEventsLastEventTimeStrategyResponse
|
? TimelineEventsLastEventTimeStrategyResponse
|
||||||
: never;
|
: never;
|
||||||
|
@ -54,6 +57,8 @@ export type TimelineStrategyRequestType<
|
||||||
? TimelineEventsAllRequestOptions
|
? TimelineEventsAllRequestOptions
|
||||||
: T extends TimelineEventsQueries.details
|
: T extends TimelineEventsQueries.details
|
||||||
? TimelineEventsDetailsRequestOptions
|
? TimelineEventsDetailsRequestOptions
|
||||||
|
: T extends TimelineEventsQueries.kpi
|
||||||
|
? TimelineRequestBasicOptions
|
||||||
: T extends TimelineEventsQueries.lastEventTime
|
: T extends TimelineEventsQueries.lastEventTime
|
||||||
? TimelineEventsLastEventTimeRequestOptions
|
? TimelineEventsLastEventTimeRequestOptions
|
||||||
: never;
|
: 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';
|
} from '@elastic/eui';
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { isEmpty, get, pick } from 'lodash/fp';
|
import { isEmpty, get, pick } from 'lodash/fp';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { FormattedRelative } from '@kbn/i18n/react';
|
import { FormattedRelative } from '@kbn/i18n/react';
|
||||||
|
|
||||||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
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 { timelineActions, timelineSelectors } from '../../../store/timeline';
|
||||||
import { timelineDefaults } from '../../../../timelines/store/timeline/defaults';
|
import { timelineDefaults } from '../../../../timelines/store/timeline/defaults';
|
||||||
import { AddToFavoritesButton } from '../../timeline/properties/helpers';
|
import { AddToFavoritesButton } from '../../timeline/properties/helpers';
|
||||||
|
import { TimerangeInput } from '../../../../../common/search_strategy';
|
||||||
import { AddToCaseButton } from '../add_to_case_button';
|
import { AddToCaseButton } from '../add_to_case_button';
|
||||||
import { AddTimelineButton } from '../add_timeline_button';
|
import { AddTimelineButton } from '../add_timeline_button';
|
||||||
import { SaveTimelineButton } from '../../timeline/header/save_timeline_button';
|
import { SaveTimelineButton } from '../../timeline/header/save_timeline_button';
|
||||||
|
import { useKibana } from '../../../../common/lib/kibana';
|
||||||
import { InspectButton } from '../../../../common/components/inspect';
|
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 { ActiveTimelines } from './active_timelines';
|
||||||
import * as i18n from './translations';
|
import * as i18n from './translations';
|
||||||
import * as commonI18n from '../../timeline/properties/translations';
|
import * as commonI18n from '../../timeline/properties/translations';
|
||||||
import { getTimelineStatusByIdSelector } from './selectors';
|
import { getTimelineStatusByIdSelector } from './selectors';
|
||||||
import { focusActiveTimelineButton } from '../../timeline/helpers';
|
import { TimelineKPIs } from './kpis';
|
||||||
|
|
||||||
// to hide side borders
|
// to hide side borders
|
||||||
const StyledPanel = styled(EuiPanel)`
|
const StyledPanel = styled(EuiPanel)`
|
||||||
|
@ -227,38 +244,106 @@ const TimelineStatusInfoComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }
|
||||||
|
|
||||||
const TimelineStatusInfo = React.memo(TimelineStatusInfoComponent);
|
const TimelineStatusInfo = React.memo(TimelineStatusInfoComponent);
|
||||||
|
|
||||||
const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => (
|
const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => {
|
||||||
<StyledTimelineHeader alignItems="center">
|
const { selectedPatterns, indexPattern, docValueFields, browserFields } = useSourcererScope(
|
||||||
<EuiFlexItem>
|
SourcererScopeName.timeline
|
||||||
<EuiFlexGroup data-test-subj="properties-left" direction="column" gutterSize="none">
|
);
|
||||||
<RowFlexItem>
|
const getStartSelector = useMemo(() => startSelector(), []);
|
||||||
<TimelineName timelineId={timelineId} />
|
const getEndSelector = useMemo(() => endSelector(), []);
|
||||||
<SaveTimelineButton timelineId={timelineId} initialFocus="title" />
|
const isActive = useMemo(() => timelineId === TimelineId.active, [timelineId]);
|
||||||
</RowFlexItem>
|
const timerange: TimerangeInput = useDeepEqualSelector((state) => {
|
||||||
<RowFlexItem>
|
if (isActive) {
|
||||||
<TimelineDescription timelineId={timelineId} />
|
return {
|
||||||
<SaveTimelineButton timelineId={timelineId} initialFocus="description" />
|
from: getStartSelector(state.inputs.timeline),
|
||||||
</RowFlexItem>
|
to: getEndSelector(state.inputs.timeline),
|
||||||
<EuiFlexItem>
|
interval: '',
|
||||||
<TimelineStatusInfo timelineId={timelineId} />
|
};
|
||||||
</EuiFlexItem>
|
} else {
|
||||||
</EuiFlexGroup>
|
return {
|
||||||
</EuiFlexItem>
|
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}>
|
const isBlankTimeline: boolean = useMemo(
|
||||||
<EuiFlexGroup gutterSize="s">
|
() => isEmpty(dataProviders) && isEmpty(filters) && isEmpty(kqlQuery.query),
|
||||||
<EuiFlexItem grow={false}>
|
[dataProviders, filters, kqlQuery]
|
||||||
<AddToFavoritesButton timelineId={timelineId} />
|
);
|
||||||
</EuiFlexItem>
|
const combinedQueries = useMemo(
|
||||||
<EuiFlexItem grow={false}>
|
() =>
|
||||||
<AddToCaseButton timelineId={timelineId} />
|
combineQueries({
|
||||||
</EuiFlexItem>
|
config: esQueryConfig,
|
||||||
</EuiFlexGroup>
|
dataProviders,
|
||||||
</EuiFlexItem>
|
indexPattern,
|
||||||
</StyledTimelineHeader>
|
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';
|
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 = ({
|
export const TIMELINE_TOGGLE_BUTTON_ARIA_LABEL = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
title,
|
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 { SecuritySolutionTimelineFactory } from '../types';
|
||||||
import { timelineEventsAll } from './all';
|
import { timelineEventsAll } from './all';
|
||||||
import { timelineEventsDetails } from './details';
|
import { timelineEventsDetails } from './details';
|
||||||
|
import { timelineKpi } from './kpi';
|
||||||
import { timelineEventsLastEventTime } from './last_event_time';
|
import { timelineEventsLastEventTime } from './last_event_time';
|
||||||
|
|
||||||
export const timelineEventsFactory: Record<
|
export const timelineEventsFactory: Record<
|
||||||
|
@ -20,5 +21,6 @@ export const timelineEventsFactory: Record<
|
||||||
> = {
|
> = {
|
||||||
[TimelineEventsQueries.all]: timelineEventsAll,
|
[TimelineEventsQueries.all]: timelineEventsAll,
|
||||||
[TimelineEventsQueries.details]: timelineEventsDetails,
|
[TimelineEventsQueries.details]: timelineEventsDetails,
|
||||||
|
[TimelineEventsQueries.kpi]: timelineKpi,
|
||||||
[TimelineEventsQueries.lastEventTime]: timelineEventsLastEventTime,
|
[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) {
|
export default function ({ getService }: FtrProviderContext) {
|
||||||
const esArchiver = getService('esArchiver');
|
const esArchiver = getService('esArchiver');
|
||||||
const supertest = getService('supertest');
|
const supertest = getService('supertest');
|
||||||
|
@ -587,5 +595,24 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
})
|
})
|
||||||
).to.eql(sortBy(EXPECTED_DATA, 'name'));
|
).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