Skip to content

Commit

Permalink
[Secutiy Solution] Timeline kpis (#89210)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people committed Feb 2, 2021
1 parent 56ae0b7 commit bd87bcf
Show file tree
Hide file tree
Showing 12 changed files with 631 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export * from './last_event_time';
export enum TimelineEventsQueries {
all = 'eventsAll',
details = 'eventsDetails',
kpi = 'eventsKpi',
lastEventTime = 'eventsLastEventTime',
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
TimelineEventsDetailsStrategyResponse,
TimelineEventsLastEventTimeRequestOptions,
TimelineEventsLastEventTimeStrategyResponse,
TimelineKpiStrategyResponse,
} from './events';
import { DocValueFields, PaginationInputPaginated, TimerangeInput, SortField } from '../common';

Expand Down Expand Up @@ -44,6 +45,8 @@ export type TimelineStrategyResponseType<
? TimelineEventsAllStrategyResponse
: T extends TimelineEventsQueries.details
? TimelineEventsDetailsStrategyResponse
: T extends TimelineEventsQueries.kpi
? TimelineKpiStrategyResponse
: T extends TimelineEventsQueries.lastEventTime
? TimelineEventsLastEventTimeStrategyResponse
: never;
Expand All @@ -54,6 +57,8 @@ export type TimelineStrategyRequestType<
? TimelineEventsAllRequestOptions
: T extends TimelineEventsQueries.details
? TimelineEventsDetailsRequestOptions
: T extends TimelineEventsQueries.kpi
? TimelineRequestBasicOptions
: T extends TimelineEventsQueries.lastEventTime
? TimelineEventsLastEventTimeRequestOptions
: never;
Original file line number Diff line number Diff line change
@@ -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())
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down Expand Up @@ -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';

Expand Down
Loading

0 comments on commit bd87bcf

Please sign in to comment.