From c878857d36b9735113a9730a34f31608a4f64fa1 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Wed, 25 Sep 2019 06:07:11 +0100 Subject: [PATCH] [SIEM] Add events histogram (#45403) (#46536) * add events histogram * move away from auto date histogram aggregation * fallback to auto-date-histogram if interval is not available * styling for histogram * add add narrowDownTimerange event * isolate matrixOverTimeHistogram component * split events series * add lable for missing group * fix styled component syntax + change prop name * add unit test * fix props passed to styled component * add integration test * pass filterQuery prop to events tab on Host details page * reduce bucket number for bar chart * update types * display barchart only when data is available * change function style * fix types * add unit test * add spacer * pass updateDateRange down to host details components * update snapshot --- .../components/charts/areachart.test.tsx | 70 +++- .../public/components/charts/areachart.tsx | 82 ++-- .../components/charts/barchart.test.tsx | 65 ++- .../public/components/charts/barchart.tsx | 81 ++-- .../public/components/charts/common.test.tsx | 132 ++++++ .../siem/public/components/charts/common.tsx | 60 ++- .../matrix_over_time/index.test.tsx | 84 ++++ .../components/matrix_over_time/index.tsx | 140 +++++++ .../page/hosts/events_over_time/index.tsx | 26 ++ .../hosts/events_over_time/translation.ts | 31 ++ .../__snapshots__/index.test.tsx.snap | 318 +++----------- .../components/stat_items/index.test.tsx | 8 + .../public/components/stat_items/index.tsx | 15 +- .../events_over_time.gql_query.ts | 37 ++ .../events/events_over_time/index.tsx | 108 +++++ .../siem/public/graphql/introspection.json | 149 +++++++ .../plugins/siem/public/graphql/types.ts | 77 ++++ .../siem/public/pages/hosts/details/body.tsx | 3 + .../siem/public/pages/hosts/details/index.tsx | 1 - .../siem/public/pages/hosts/hosts_body.tsx | 3 + .../public/pages/hosts/hosts_navigations.tsx | 387 ++++++++++++++++++ .../navigation/events_query_tab_body.tsx | 52 ++- .../public/pages/hosts/navigation/types.ts | 2 + .../siem/server/graphql/events/resolvers.ts | 13 + .../siem/server/graphql/events/schema.gql.ts | 17 + .../plugins/siem/server/graphql/types.ts | 92 +++++ .../lib/events/elasticsearch_adapter.ts | 63 ++- .../plugins/siem/server/lib/events/index.ts | 10 +- .../lib/events/query.events_over_time.dsl.ts | 79 ++++ .../plugins/siem/server/lib/events/types.ts | 26 +- .../calculate_timeseries_interval.ts | 102 +++++ .../siem/server/utils/build_query/index.ts | 1 + .../apis/siem/events_over_time.ts | 98 +++++ .../test/api_integration/apis/siem/index.js | 1 + 34 files changed, 2027 insertions(+), 406 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/charts/common.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/translation.ts create mode 100644 x-pack/legacy/plugins/siem/public/containers/events/events_over_time/events_over_time.gql_query.ts create mode 100644 x-pack/legacy/plugins/siem/public/containers/events/events_over_time/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/hosts/hosts_navigations.tsx create mode 100644 x-pack/legacy/plugins/siem/server/lib/events/query.events_over_time.dsl.ts create mode 100644 x-pack/legacy/plugins/siem/server/utils/build_query/calculate_timeseries_interval.ts create mode 100644 x-pack/test/api_integration/apis/siem/events_over_time.ts diff --git a/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx index fb438e0c9852c..cc6901ac170ce 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx @@ -7,15 +7,16 @@ import { ShallowWrapper, shallow } from 'enzyme'; import * as React from 'react'; -import { AreaChartBaseComponent, AreaChartWithCustomPrompt } from './areachart'; -import { ChartConfigsData, ChartHolder } from './common'; +import { AreaChartBaseComponent, AreaChartWithCustomPrompt, AreaChart } from './areachart'; +import { ChartHolder, ChartSeriesData } from './common'; import { ScaleType, AreaSeries, Axis } from '@elastic/charts'; jest.mock('@elastic/charts'); - +const customHeight = '100px'; +const customWidth = '120px'; describe('AreaChartBaseComponent', () => { let shallowWrapper: ShallowWrapper; - const mockAreaChartData: ChartConfigsData[] = [ + const mockAreaChartData: ChartSeriesData[] = [ { key: 'uniqueSourceIpsHistogram', value: [ @@ -39,7 +40,11 @@ describe('AreaChartBaseComponent', () => { describe('render', () => { beforeAll(() => { shallowWrapper = shallow( - + ); }); @@ -65,8 +70,8 @@ describe('AreaChartBaseComponent', () => { beforeAll(() => { shallowWrapper = shallow( @@ -118,7 +123,11 @@ describe('AreaChartBaseComponent', () => { describe('render with default configs if no customized configs given', () => { beforeAll(() => { shallowWrapper = shallow( - + ); }); @@ -220,9 +229,11 @@ describe('AreaChartWithCustomPrompt', () => { ], ], ], - ])('renders areachart', (data: ChartConfigsData[] | [] | null | undefined) => { + ])('renders areachart', (data: ChartSeriesData[] | [] | null | undefined) => { beforeAll(() => { - shallowWrapper = shallow(); + shallowWrapper = shallow( + + ); }); it('render AreaChartBaseComponent', () => { @@ -310,9 +321,11 @@ describe('AreaChartWithCustomPrompt', () => { }, ], ], - ])('renders prompt', (data: ChartConfigsData[] | [] | null | undefined) => { + ])('renders prompt', (data: ChartSeriesData[] | [] | null | undefined) => { beforeAll(() => { - shallowWrapper = shallow(); + shallowWrapper = shallow( + + ); }); it('render Chart Holder', () => { @@ -321,3 +334,36 @@ describe('AreaChartWithCustomPrompt', () => { }); }); }); + +describe('AreaChart', () => { + let shallowWrapper: ShallowWrapper; + const mockConfig = { + series: { + xScaleType: ScaleType.Time, + yScaleType: ScaleType.Linear, + stackAccessors: ['g'], + }, + axis: { + xTickFormatter: jest.fn(), + yTickFormatter: jest.fn(), + tickSize: 8, + }, + customHeight: 324, + }; + + it('should render if data exist', () => { + const mockData = [ + { key: 'uniqueSourceIps', value: [{ y: 100, x: 100, g: 'group' }], color: '#DB1374' }, + ]; + shallowWrapper = shallow(); + expect(shallowWrapper.find('AutoSizer')).toHaveLength(1); + expect(shallowWrapper.find('ChartHolder')).toHaveLength(0); + }); + + it('should render a chartHolder if no data given', () => { + const mockData = [{ key: 'uniqueSourceIps', value: [], color: '#DB1374' }]; + shallowWrapper = shallow(); + expect(shallowWrapper.find('AutoSizer')).toHaveLength(0); + expect(shallowWrapper.find('ChartHolder')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx b/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx index 601e4dceeca8d..c4bb01a66753b 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx @@ -19,14 +19,15 @@ import { } from '@elastic/charts'; import { getOr, get } from 'lodash/fp'; import { - ChartConfigsData, + ChartSeriesData, ChartHolder, getSeriesStyle, WrappedByAutoSizer, - getTheme, ChartSeriesConfigs, browserTimezone, chartDefaultSettings, + getChartHeight, + getChartWidth, } from './common'; import { AutoSizer } from '../auto_sizer'; @@ -52,9 +53,9 @@ const getSeriesLineStyle = (): RecursivePartial => { // https://ela.st/multi-areaseries export const AreaChartBaseComponent = React.memo<{ - data: ChartConfigsData[]; - width: number | null | undefined; - height: number | null | undefined; + data: ChartSeriesData[]; + width: string | null | undefined; + height: string | null | undefined; configs?: ChartSeriesConfigs | undefined; }>(({ data, ...chartConfigs }) => { const xTickFormatter = get('configs.axis.xTickFormatter', chartConfigs); @@ -68,7 +69,7 @@ export const AreaChartBaseComponent = React.memo<{ return chartConfigs.width && chartConfigs.height ? (
- + {data.map(series => { const seriesKey = series.key; const seriesSpecId = getSpecId(seriesKey); @@ -89,23 +90,15 @@ export const AreaChartBaseComponent = React.memo<{ ) : null; })} - {xTickFormatter ? ( - - ) : ( - - )} + - {yTickFormatter ? ( - - ) : ( - - )} +
) : null; @@ -114,9 +107,9 @@ export const AreaChartBaseComponent = React.memo<{ AreaChartBaseComponent.displayName = 'AreaChartBaseComponent'; export const AreaChartWithCustomPrompt = React.memo<{ - data: ChartConfigsData[] | null | undefined; - height: number | null | undefined; - width: number | null | undefined; + data: ChartSeriesData[] | null | undefined; + height: string | null | undefined; + width: string | null | undefined; configs?: ChartSeriesConfigs | undefined; }>(({ data, height, width, configs }) => { return data != null && @@ -129,28 +122,35 @@ export const AreaChartWithCustomPrompt = React.memo<{ ) ? ( ) : ( - + ); }); AreaChartWithCustomPrompt.displayName = 'AreaChartWithCustomPrompt'; export const AreaChart = React.memo<{ - areaChart: ChartConfigsData[] | null | undefined; + areaChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; -}>(({ areaChart, configs }) => ( - - {({ measureRef, content: { height, width } }) => ( - - - - )} - -)); +}>(({ areaChart, configs }) => { + const customHeight = get('customHeight', configs); + const customWidth = get('customWidth', configs); + + return get(`0.value.length`, areaChart) ? ( + + {({ measureRef, content: { height, width } }) => ( + + + + )} + + ) : ( + + ); +}); AreaChart.displayName = 'AreaChart'; diff --git a/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx index 7693a6c6d925d..e6ede5f085b76 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx @@ -7,15 +7,16 @@ import { shallow, ShallowWrapper } from 'enzyme'; import * as React from 'react'; -import { BarChartBaseComponent, BarChartWithCustomPrompt } from './barchart'; -import { ChartConfigsData, ChartHolder } from './common'; +import { BarChartBaseComponent, BarChartWithCustomPrompt, BarChart } from './barchart'; +import { ChartSeriesData, ChartHolder } from './common'; import { BarSeries, ScaleType, Axis } from '@elastic/charts'; jest.mock('@elastic/charts'); - +const customHeight = '100px'; +const customWidth = '120px'; describe('BarChartBaseComponent', () => { let shallowWrapper: ShallowWrapper; - const mockBarChartData: ChartConfigsData[] = [ + const mockBarChartData: ChartSeriesData[] = [ { key: 'uniqueSourceIps', value: [{ y: 1714, x: 'uniqueSourceIps', g: 'uniqueSourceIps' }], @@ -31,7 +32,7 @@ describe('BarChartBaseComponent', () => { describe('render', () => { beforeAll(() => { shallowWrapper = shallow( - + ); }); @@ -54,7 +55,12 @@ describe('BarChartBaseComponent', () => { beforeAll(() => { shallowWrapper = shallow( - + ); }); @@ -103,7 +109,7 @@ describe('BarChartBaseComponent', () => { describe('render with default configs if no customized configs given', () => { beforeAll(() => { shallowWrapper = shallow( - + ); }); @@ -198,7 +204,11 @@ describe.each([ describe('renders barchart', () => { beforeAll(() => { shallowWrapper = shallow( - + ); }); @@ -271,10 +281,12 @@ describe.each([ }, ], ], -])('renders prompt', (data: ChartConfigsData[] | [] | null | undefined) => { +])('renders prompt', (data: ChartSeriesData[] | [] | null | undefined) => { let shallowWrapper: ShallowWrapper; beforeAll(() => { - shallowWrapper = shallow(); + shallowWrapper = shallow( + + ); }); it('render Chart Holder', () => { @@ -282,3 +294,36 @@ describe.each([ expect(shallowWrapper.find(ChartHolder)).toHaveLength(1); }); }); + +describe('BarChart', () => { + let shallowWrapper: ShallowWrapper; + const mockConfig = { + series: { + xScaleType: ScaleType.Time, + yScaleType: ScaleType.Linear, + stackAccessors: ['g'], + }, + axis: { + xTickFormatter: jest.fn(), + yTickFormatter: jest.fn(), + tickSize: 8, + }, + customHeight: 324, + }; + + it('should render if data exist', () => { + const mockData = [ + { key: 'uniqueSourceIps', value: [{ y: 100, x: 100, g: 'group' }], color: '#DB1374' }, + ]; + shallowWrapper = shallow(); + expect(shallowWrapper.find('AutoSizer')).toHaveLength(1); + expect(shallowWrapper.find('ChartHolder')).toHaveLength(0); + }); + + it('should render a chartHolder if no data given', () => { + const mockData = [{ key: 'uniqueSourceIps', value: [], color: '#DB1374' }]; + shallowWrapper = shallow(); + expect(shallowWrapper.find('AutoSizer')).toHaveLength(0); + expect(shallowWrapper.find('ChartHolder')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx index b90ff6d7a6cc8..02345fc149c2a 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx @@ -18,27 +18,29 @@ import { } from '@elastic/charts'; import { getOr, get } from 'lodash/fp'; import { - ChartConfigsData, + ChartSeriesData, WrappedByAutoSizer, ChartHolder, SeriesType, getSeriesStyle, - getTheme, ChartSeriesConfigs, browserTimezone, chartDefaultSettings, + getChartHeight, + getChartWidth, } from './common'; import { AutoSizer } from '../auto_sizer'; // Bar chart rotation: https://ela.st/chart-rotations export const BarChartBaseComponent = React.memo<{ - data: ChartConfigsData[]; - width: number | null | undefined; - height: number | null | undefined; + data: ChartSeriesData[]; + width: string | null | undefined; + height: string | null | undefined; configs?: ChartSeriesConfigs | undefined; }>(({ data, ...chartConfigs }) => { const xTickFormatter = get('configs.axis.xTickFormatter', chartConfigs); const yTickFormatter = get('configs.axis.yTickFormatter', chartConfigs); + const tickSize = getOr(0, 'configs.axis.tickSize', chartConfigs); const xAxisId = getAxisId(`stat-items-barchart-${data[0].key}-x`); const yAxisId = getAxisId(`stat-items-barchart-${data[0].key}-y`); const settings = { @@ -47,7 +49,7 @@ export const BarChartBaseComponent = React.memo<{ }; return chartConfigs.width && chartConfigs.height ? ( - + {data.map(series => { const barSeriesKey = series.key; const barSeriesSpecId = getSpecId(barSeriesKey); @@ -64,29 +66,21 @@ export const BarChartBaseComponent = React.memo<{ timeZone={browserTimezone} splitSeriesAccessors={['g']} data={series.value!} - stackAccessors={['y']} + stackAccessors={get('configs.series.stackAccessors', chartConfigs)} customSeriesColors={getSeriesStyle(barSeriesKey, series.color, seriesType)} /> ); })} - {xTickFormatter ? ( - - ) : ( - - )} + - {yTickFormatter ? ( - - ) : ( - - )} + ) : null; }); @@ -94,36 +88,47 @@ export const BarChartBaseComponent = React.memo<{ BarChartBaseComponent.displayName = 'BarChartBaseComponent'; export const BarChartWithCustomPrompt = React.memo<{ - data: ChartConfigsData[] | null | undefined; - height: number | null | undefined; - width: number | null | undefined; + data: ChartSeriesData[] | null | undefined; + height: string | null | undefined; + width: string | null | undefined; configs?: ChartSeriesConfigs | undefined; }>(({ data, height, width, configs }) => { return data && data.length && data.some( ({ value }) => - value != null && value.length > 0 && value.every(chart => chart.y != null && chart.y > 0) + value != null && value.length > 0 && value.every(chart => chart.y != null && chart.y >= 0) ) ? ( ) : ( - + ); }); BarChartWithCustomPrompt.displayName = 'BarChartWithCustomPrompt'; export const BarChart = React.memo<{ - barChart: ChartConfigsData[] | null | undefined; + barChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; -}>(({ barChart, configs }) => ( - - {({ measureRef, content: { height, width } }) => ( - - - - )} - -)); +}>(({ barChart, configs }) => { + const customHeight = get('customHeight', configs); + const customWidth = get('customWidth', configs); + return get(`0.value.length`, barChart) ? ( + + {({ measureRef, content: { height, width } }) => ( + + + + )} + + ) : ( + + ); +}); BarChart.displayName = 'BarChart'; diff --git a/x-pack/legacy/plugins/siem/public/components/charts/common.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/common.test.tsx new file mode 100644 index 0000000000000..f23b97d8cd5ff --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/charts/common.test.tsx @@ -0,0 +1,132 @@ +/* + * 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 { shallow, ShallowWrapper } from 'enzyme'; +import * as React from 'react'; +import { + ChartHolder, + getChartHeight, + getChartWidth, + WrappedByAutoSizer, + defaultChartHeight, + getSeriesStyle, + SeriesType, + getTheme, +} from './common'; +import 'jest-styled-components'; +import { mergeWithDefaultTheme, LIGHT_THEME } from '@elastic/charts'; + +jest.mock('@elastic/charts', () => { + return { + getSpecId: jest.fn(() => {}), + mergeWithDefaultTheme: jest.fn(), + }; +}); + +describe('ChartHolder', () => { + let shallowWrapper: ShallowWrapper; + + it('should render with default props', () => { + const height = `100%`; + const width = `100%`; + shallowWrapper = shallow(); + expect(shallowWrapper.props()).toMatchObject({ + height, + width, + }); + }); + + it('should render with given props', () => { + const height = `100px`; + const width = `100px`; + shallowWrapper = shallow(); + expect(shallowWrapper.props()).toMatchObject({ + height, + width, + }); + }); +}); + +describe('WrappedByAutoSizer', () => { + it('should render correct default height', () => { + const wrapper = shallow(); + expect(wrapper).toHaveStyleRule('height', defaultChartHeight); + }); + + it('should render correct given height', () => { + const wrapper = shallow(); + expect(wrapper).toHaveStyleRule('height', '100px'); + }); +}); + +describe('getSeriesStyle', () => { + it('should not create style mapping if color is not given', () => { + const mockSeriesKey = 'mockSeriesKey'; + const color = ''; + const customSeriesColors = getSeriesStyle(mockSeriesKey, color, SeriesType.BAR); + expect(customSeriesColors).toBeUndefined(); + }); + + it('should create correct style mapping for series of a chart', () => { + const mockSeriesKey = 'mockSeriesKey'; + const color = 'red'; + const customSeriesColors = getSeriesStyle(mockSeriesKey, color, SeriesType.BAR); + const expectedKey = { colorValues: [mockSeriesKey] }; + customSeriesColors!.forEach((value, key) => { + expect(JSON.stringify(key)).toEqual(JSON.stringify(expectedKey)); + expect(value).toEqual(color); + }); + }); +}); + +describe('getTheme', () => { + it('should merge custom theme with default theme', () => { + const defaultTheme = { + chartMargins: { bottom: 0, left: 0, right: 0, top: 4 }, + chartPaddings: { bottom: 0, left: 0, right: 0, top: 0 }, + scales: { + barsPadding: 0.5, + }, + }; + getTheme(); + expect((mergeWithDefaultTheme as jest.Mock).mock.calls[0][0]).toMatchObject(defaultTheme); + expect((mergeWithDefaultTheme as jest.Mock).mock.calls[0][1]).toEqual(LIGHT_THEME); + }); +}); + +describe('getChartHeight', () => { + it('should render customHeight', () => { + const height = getChartHeight(10, 100); + expect(height).toEqual('10px'); + }); + + it('should render autoSizerHeight if customHeight is not given', () => { + const height = getChartHeight(undefined, 100); + expect(height).toEqual('100px'); + }); + + it('should render defaultChartHeight if no custom data is given', () => { + const height = getChartHeight(); + expect(height).toEqual(defaultChartHeight); + }); +}); + +describe('getChartWidth', () => { + it('should render customWidth', () => { + const height = getChartWidth(10, 100); + expect(height).toEqual('10px'); + }); + + it('should render autoSizerHeight if customHeight is not given', () => { + const height = getChartWidth(undefined, 100); + expect(height).toEqual('100px'); + }); + + it('should render defaultChartHeight if no custom data is given', () => { + const height = getChartWidth(); + expect(height).toEqual(defaultChartHeight); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/common.tsx b/x-pack/legacy/plugins/siem/public/components/charts/common.tsx index 36793d6a0f6ae..59873b2cd6a31 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/common.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/common.tsx @@ -24,20 +24,27 @@ import { i18n } from '@kbn/i18n'; import chrome from 'ui/chrome'; import moment from 'moment-timezone'; import { DEFAULT_DATE_FORMAT_TZ, DEFAULT_DARK_MODE } from '../../../common/constants'; - -const chartHeight = 74; +export const defaultChartHeight = '100%'; +export const defaultChartWidth = '100%'; const chartDefaultRotation: Rotation = 0; const chartDefaultRendering: Rendering = 'canvas'; -const FlexGroup = styled(EuiFlexGroup)` - height: 100%; +const FlexGroup = styled(EuiFlexGroup)<{ height?: string | null; width?: string | null }>` + height: ${({ height }) => (height ? height : '100%')}; + width: ${({ width }) => (width ? width : '100%')}; `; FlexGroup.displayName = 'FlexGroup'; export type UpdateDateRange = (min: number, max: number) => void; -export const ChartHolder = () => ( - +export const ChartHolder = ({ + height = '100%', + width = '100%', +}: { + height?: string | null; + width?: string | null; +}) => ( + {i18n.translate('xpack.siem.chart.dataNotAvailableTitle', { @@ -48,15 +55,6 @@ export const ChartHolder = () => ( ); -export const chartDefaultSettings = { - rotation: chartDefaultRotation, - rendering: chartDefaultRendering, - animatedData: false, - showLegend: false, - showLegendDisplayValue: false, - debug: false, -}; - export interface ChartData { x: number | string | null; y: number | string | null; @@ -65,6 +63,7 @@ export interface ChartData { } export interface ChartSeriesConfigs { + customHeight?: number; series?: { xScaleType?: ScaleType | undefined; yScaleType?: ScaleType | undefined; @@ -76,16 +75,17 @@ export interface ChartSeriesConfigs { settings?: Partial; } -export interface ChartConfigsData { +export interface ChartSeriesData { key: string; value: ChartData[] | [] | null; color?: string | undefined; - areachartConfigs?: ChartSeriesConfigs | undefined; - barchartConfigs?: ChartSeriesConfigs | undefined; } -export const WrappedByAutoSizer = styled.div` - height: ${chartHeight}px; +export const WrappedByAutoSizer = styled.div<{ height?: string }>` + ${style => + ` + height: ${style.height != null ? style.height : defaultChartHeight}; + `} position: relative; &:hover { @@ -144,5 +144,25 @@ export const getTheme = () => { return mergeWithDefaultTheme(theme, defaultTheme); }; +export const chartDefaultSettings = { + rotation: chartDefaultRotation, + rendering: chartDefaultRendering, + animatedData: false, + showLegend: false, + showLegendDisplayValue: false, + debug: false, + theme: getTheme(), +}; + const kibanaTimezone: string = chrome.getUiSettingsClient().get(DEFAULT_DATE_FORMAT_TZ); export const browserTimezone = kibanaTimezone === 'Browser' ? moment.tz.guess() : kibanaTimezone; + +export const getChartHeight = (customHeight?: number, autoSizerHeight?: number): string => { + const height = customHeight || autoSizerHeight; + return height ? `${height}px` : defaultChartHeight; +}; + +export const getChartWidth = (customWidth?: number, autoSizerWidth?: number): string => { + const height = customWidth || autoSizerWidth; + return height ? `${height}px` : defaultChartWidth; +}; diff --git a/x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.test.tsx new file mode 100644 index 0000000000000..e52d44173f656 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 { shallow } from 'enzyme'; +import * as React from 'react'; + +import { MatrixOverTimeHistogram } from '.'; + +jest.mock('@elastic/eui', () => { + return { + EuiPanel: (children: JSX.Element) => <>{children}, + EuiLoadingContent: () =>
, + }; +}); + +jest.mock('../loader', () => { + return { + Loader: () =>
, + }; +}); + +jest.mock('../../lib/settings/use_kibana_ui_setting', () => { + return { useKibanaUiSetting: () => [false] }; +}); + +jest.mock('../header_panel', () => { + return { + HeaderPanel: () =>
, + }; +}); + +jest.mock('../charts/barchart', () => { + return { + BarChart: () =>
, + }; +}); + +describe('Load More Events Table Component', () => { + const mockMatrixOverTimeHistogramProps = { + data: [], + dataKey: 'mockDataKey', + endDate: new Date('2019-07-18T20:00:00.000Z').valueOf(), + id: 'mockId', + loading: true, + updateDateRange: () => {}, + startDate: new Date('2019-07-18T19:00: 00.000Z').valueOf(), + subtitle: 'mockSubtitle', + totalCount: -1, + title: 'mockTitle', + }; + describe('rendering', () => { + test('it renders EuiLoadingContent on initialLoad', () => { + const wrapper = shallow(); + + expect(wrapper.find(`[data-test-subj="initialLoadingPanelMatrixOverTime"]`)).toBeTruthy(); + }); + + test('it renders Loader while fetching data if visited before', () => { + const mockProps = { + ...mockMatrixOverTimeHistogramProps, + data: [{ x: new Date('2019-09-16T02:20:00.000Z').valueOf(), y: 3787, g: 'config_change' }], + totalCount: 10, + loading: true, + }; + const wrapper = shallow(); + expect(wrapper.find('.loader')).toBeTruthy(); + }); + + test('it renders BarChart if data available', () => { + const mockProps = { + ...mockMatrixOverTimeHistogramProps, + data: [{ x: new Date('2019-09-16T02:20:00.000Z').valueOf(), y: 3787, g: 'config_change' }], + totalCount: 10, + loading: false, + }; + const wrapper = shallow(); + + expect(wrapper.find(`.barchart`)).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.tsx b/x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.tsx new file mode 100644 index 0000000000000..f95b9e6b3ecf5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.tsx @@ -0,0 +1,140 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { ScaleType, niceTimeFormatter, Position } from '@elastic/charts'; + +import { getOr, head, last } from 'lodash/fp'; +import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; +import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; +import { EuiLoadingContent } from '@elastic/eui'; +import { BarChart } from '../charts/barchart'; +import { HeaderPanel } from '../header_panel'; +import { ChartSeriesData, UpdateDateRange } from '../charts/common'; +import { MatrixOverTimeHistogramData } from '../../graphql/types'; +import { DEFAULT_DARK_MODE } from '../../../common/constants'; +import { useKibanaUiSetting } from '../../lib/settings/use_kibana_ui_setting'; +import { Loader } from '../loader'; +import { Panel } from '../panel'; + +export interface MatrixOverTimeBasicProps { + id: string; + data: MatrixOverTimeHistogramData[]; + loading: boolean; + startDate: number; + endDate: number; + updateDateRange: UpdateDateRange; + totalCount: number; +} + +export interface MatrixOverTimeProps extends MatrixOverTimeBasicProps { + title: string; + subtitle: string; + dataKey: string; +} + +const getBarchartConfigs = (from: number, to: number, onBrushEnd: UpdateDateRange) => ({ + series: { + xScaleType: ScaleType.Time, + yScaleType: ScaleType.Linear, + stackAccessors: ['g'], + }, + axis: { + xTickFormatter: niceTimeFormatter([from, to]), + yTickFormatter: (value: string | number): string => value.toLocaleString(), + tickSize: 8, + }, + settings: { + legendPosition: Position.Bottom, + onBrushEnd, + showLegend: true, + theme: { + scales: { + barsPadding: 0.05, + }, + chartMargins: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + chartPaddings: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + }, + customHeight: 324, +}); + +export const MatrixOverTimeHistogram = ({ + id, + loading, + data, + dataKey, + endDate, + updateDateRange, + startDate, + title, + subtitle, + totalCount, +}: MatrixOverTimeProps) => { + const bucketStartDate = getOr(startDate, 'x', head(data)); + const bucketEndDate = getOr(endDate, 'x', last(data)); + const barchartConfigs = getBarchartConfigs(bucketStartDate!, bucketEndDate!, updateDateRange); + const [showInspect, setShowInspect] = useState(false); + const [darkMode] = useKibanaUiSetting(DEFAULT_DARK_MODE); + const [loadingInitial, setLoadingInitial] = useState(false); + + const barChartData: ChartSeriesData[] = [ + { + key: dataKey, + value: data, + }, + ]; + + useEffect(() => { + if (totalCount >= 0 && loadingInitial) { + setLoadingInitial(false); + } + }, [loading]); + + return ( + setShowInspect(true)} + onMouseLeave={() => setShowInspect(false)} + > + + + {loadingInitial ? ( + + ) : ( + <> + + + {loading && ( + + )} + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/index.tsx new file mode 100644 index 0000000000000..8b41619199653 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/index.tsx @@ -0,0 +1,26 @@ +/* + * 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 * as i18n from './translation'; +import { MatrixOverTimeHistogram, MatrixOverTimeBasicProps } from '../../../matrix_over_time'; + +export const EventsOverTimeHistogram = (props: MatrixOverTimeBasicProps) => { + const dataKey = 'eventsOverTime'; + const { totalCount } = props; + const subtitle = `${i18n.SHOWING}: ${totalCount.toLocaleString()} ${i18n.UNIT(totalCount)}`; + const { ...matrixOverTimeProps } = props; + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/translation.ts b/x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/translation.ts new file mode 100644 index 0000000000000..a2d7036f05036 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/events_over_time/translation.ts @@ -0,0 +1,31 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const EVENT_COUNT_FREQUENCY_BY_ACTION = i18n.translate( + 'xpack.siem.eventsOverTime.eventCountFrequencyByActionTitle', + { + defaultMessage: 'Event count frequency by action', + } +); + +export const LOADING_EVENTS_OVER_TIME = i18n.translate( + 'xpack.siem.eventsOverTime.loadingEventsOverTimeTitle', + { + defaultMessage: 'Loading events histogram', + } +); + +export const SHOWING = i18n.translate('xpack.siem.eventsOverTime.showing', { + defaultMessage: 'Showing', +}); + +export const UNIT = (totalCount: number) => + i18n.translate('xpack.siem.eventsOverTime.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {event} other {events}}`, + }); diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index 4bbb4b7e4811f..9541ad4de043b 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -38,11 +38,11 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] = data-test-subj="stat-item" >

@@ -292,11 +292,11 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] = data-test-subj="stat-item" >

@@ -616,11 +616,11 @@ exports[`Stat Items Component rendering kpis with charts it renders the default data-test-subj="stat-item" >

1,714 @@ -857,10 +857,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default key="stat-items-field-uniqueDestinationIps" >

2,359 @@ -957,10 +957,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default >

- - -
- - - -
-
-
+
- - -
- - - -
-
-
+
diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/stat_items/index.test.tsx index 4887a5c32b59d..8453ec1cfb5d7 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/index.test.tsx @@ -37,6 +37,14 @@ import { KpiNetworkData, KpiHostsData } from '../../graphql/types'; const from = new Date('2019-06-15T06:00:00.000Z').valueOf(); const to = new Date('2019-06-18T06:00:00.000Z').valueOf(); +jest.mock('../charts/areachart', () => { + return { AreaChart: () =>
}; +}); + +jest.mock('../charts/barchart', () => { + return { BarChart: () =>
}; +}); + describe('Stat Items Component', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); const state: State = mockGlobalState; diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx b/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx index b52184f078c6f..110d146381709 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx @@ -27,7 +27,7 @@ import styled from 'styled-components'; import { KpiHostsData, KpiNetworkData } from '../../graphql/types'; import { AreaChart } from '../charts/areachart'; import { BarChart } from '../charts/barchart'; -import { ChartConfigsData, ChartData, ChartSeriesConfigs, UpdateDateRange } from '../charts/common'; +import { ChartSeriesData, ChartData, ChartSeriesConfigs, UpdateDateRange } from '../charts/common'; import { getEmptyTagValue } from '../empty_value'; import { InspectButton } from '../inspect'; @@ -69,8 +69,8 @@ export interface StatItems { } export interface StatItemsProps extends StatItems { - areaChart?: ChartConfigsData[]; - barChart?: ChartConfigsData[]; + areaChart?: ChartSeriesData[]; + barChart?: ChartSeriesData[]; from: number; id: string; narrowDateRange: UpdateDateRange; @@ -79,6 +79,7 @@ export interface StatItemsProps extends StatItems { export const numberFormatter = (value: string | number): string => value.toLocaleString(); const statItemBarchartRotation: Rotation = 90; +const statItemChartCustomHeight = 74; export const areachartConfigs = (config?: { xTickFormatter: (value: number) => string; @@ -95,6 +96,7 @@ export const areachartConfigs = (config?: { settings: { onBrushEnd: getOr(() => {}, 'onBrushEnd', config), }, + customHeight: statItemChartCustomHeight, }); export const barchartConfigs = (config?: { onElementClick?: ElementClickListener }) => ({ @@ -109,6 +111,7 @@ export const barchartConfigs = (config?: { onElementClick?: ElementClickListener onElementClick: getOr(() => {}, 'onElementClick', config), rotation: statItemBarchartRotation, }, + customHeight: statItemChartCustomHeight, }); export const addValueToFields = ( @@ -119,7 +122,7 @@ export const addValueToFields = ( export const addValueToAreaChart = ( fields: StatItem[], data: KpiHostsData | KpiNetworkData -): ChartConfigsData[] => +): ChartSeriesData[] => fields .filter(field => get(`${field.key}Histogram`, data) != null) .map(field => ({ @@ -131,9 +134,9 @@ export const addValueToAreaChart = ( export const addValueToBarChart = ( fields: StatItem[], data: KpiHostsData | KpiNetworkData -): ChartConfigsData[] => { +): ChartSeriesData[] => { if (fields.length === 0) return []; - return fields.reduce((acc: ChartConfigsData[], field: StatItem, idx: number) => { + return fields.reduce((acc: ChartSeriesData[], field: StatItem, idx: number) => { const { key, color } = field; const y: number | null = getOr(null, key, data); const x: string = get(`${idx}.name`, fields) || getOr('', `${idx}.description`, fields); diff --git a/x-pack/legacy/plugins/siem/public/containers/events/events_over_time/events_over_time.gql_query.ts b/x-pack/legacy/plugins/siem/public/containers/events/events_over_time/events_over_time.gql_query.ts new file mode 100644 index 0000000000000..aec0a32043040 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/events/events_over_time/events_over_time.gql_query.ts @@ -0,0 +1,37 @@ +/* + * 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 gql from 'graphql-tag'; + +export const EventsOverTimeGqlQuery = gql` + query GetEventsOverTimeQuery( + $sourceId: ID! + $timerange: TimerangeInput! + $defaultIndex: [String!]! + $filterQuery: String + $inspect: Boolean! + ) { + source(id: $sourceId) { + id + EventsOverTime( + timerange: $timerange + filterQuery: $filterQuery + defaultIndex: $defaultIndex + ) { + eventsOverTime { + x + y + g + } + totalCount + inspect @include(if: $inspect) { + dsl + response + } + } + } + } +`; diff --git a/x-pack/legacy/plugins/siem/public/containers/events/events_over_time/index.tsx b/x-pack/legacy/plugins/siem/public/containers/events/events_over_time/index.tsx new file mode 100644 index 0000000000000..5ce4457792552 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/events/events_over_time/index.tsx @@ -0,0 +1,108 @@ +/* + * 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 React from 'react'; +import { Query } from 'react-apollo'; +import { connect } from 'react-redux'; + +import chrome from 'ui/chrome'; +import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; +import { inputsModel, State, inputsSelectors, hostsModel } from '../../../store'; +import { createFilter, getDefaultFetchPolicy } from '../../helpers'; +import { QueryTemplate, QueryTemplateProps } from '../../query_template'; + +import { EventsOverTimeGqlQuery } from './events_over_time.gql_query'; +import { GetEventsOverTimeQuery, MatrixOverTimeHistogramData } from '../../../graphql/types'; + +const ID = 'eventsOverTimeQuery'; + +export interface EventsArgs { + endDate: number; + eventsOverTime: MatrixOverTimeHistogramData[]; + id: string; + inspect: inputsModel.InspectQuery; + loading: boolean; + refetch: inputsModel.Refetch; + startDate: number; + totalCount: number; +} + +export interface OwnProps extends QueryTemplateProps { + children?: (args: EventsArgs) => React.ReactNode; + type: hostsModel.HostsType; +} + +export interface EventsOverTimeComponentReduxProps { + isInspected: boolean; +} + +type EventsOverTimeProps = OwnProps & EventsOverTimeComponentReduxProps; + +class EventsOverTimeComponentQuery extends QueryTemplate< + EventsOverTimeProps, + GetEventsOverTimeQuery.Query, + GetEventsOverTimeQuery.Variables +> { + public render() { + const { + children, + filterQuery, + id = ID, + isInspected, + sourceId, + startDate, + endDate, + } = this.props; + return ( + + query={EventsOverTimeGqlQuery} + fetchPolicy={getDefaultFetchPolicy()} + notifyOnNetworkStatusChange + variables={{ + filterQuery: createFilter(filterQuery), + sourceId, + timerange: { + interval: '12h', + from: startDate!, + to: endDate!, + }, + defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), + inspect: isInspected, + }} + > + {({ data, loading, refetch }) => { + const source = getOr({}, `source.EventsOverTime`, data); + const eventsOverTime = getOr([], `eventsOverTime`, source); + const totalCount = getOr(-1, 'totalCount', source); + return children!({ + endDate: endDate!, + eventsOverTime, + id, + inspect: getOr(null, 'inspect', source), + loading, + refetch, + startDate: startDate!, + totalCount, + }); + }} + + ); + } +} + +const makeMapStateToProps = () => { + const getQuery = inputsSelectors.globalQueryByIdSelector(); + const mapStateToProps = (state: State, { type, id = ID }: OwnProps) => { + const { isInspected } = getQuery(state, id); + return { + isInspected, + }; + }; + return mapStateToProps; +}; + +export const EventsOverTimeQuery = connect(makeMapStateToProps)(EventsOverTimeComponentQuery); diff --git a/x-pack/legacy/plugins/siem/public/graphql/introspection.json b/x-pack/legacy/plugins/siem/public/graphql/introspection.json index 66f3975562774..404eb53f711d3 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/siem/public/graphql/introspection.json @@ -916,6 +916,53 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "EventsOverTime", + "description": "", + "args": [ + { + "name": "timerange", + "description": "", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "INPUT_OBJECT", "name": "TimerangeInput", "ofType": null } + }, + "defaultValue": null + }, + { + "name": "filterQuery", + "description": "", + "type": { "kind": "SCALAR", "name": "String", "ofType": null }, + "defaultValue": null + }, + { + "name": "defaultIndex", + "description": "", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } + } + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "OBJECT", "name": "EventsOverTimeData", "ofType": null } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "Hosts", "description": "Gets Hosts based on timerange and specified criteria, or all events in the timerange if no criteria is specified", @@ -5451,6 +5498,108 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "EventsOverTimeData", + "description": "", + "fields": [ + { + "name": "inspect", + "description": "", + "args": [], + "type": { "kind": "OBJECT", "name": "Inspect", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "eventsOverTime", + "description": "", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "MatrixOverTimeHistogramData", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalCount", + "description": "", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MatrixOverTimeHistogramData", + "description": "", + "fields": [ + { + "name": "x", + "description": "", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "y", + "description": "", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "g", + "description": "", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "HostsSortField", diff --git a/x-pack/legacy/plugins/siem/public/graphql/types.ts b/x-pack/legacy/plugins/siem/public/graphql/types.ts index bdf18c3458a98..3e43c6d7db0ac 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/public/graphql/types.ts @@ -117,6 +117,8 @@ export interface Source { TimelineDetails: TimelineDetailsData; LastEventTime: LastEventTimeData; + + EventsOverTime: EventsOverTimeData; /** Gets Hosts based on timerange and specified criteria, or all events in the timerange if no criteria is specified */ Hosts: HostsData; @@ -847,6 +849,22 @@ export interface LastEventTimeData { inspect?: Inspect | null; } +export interface EventsOverTimeData { + inspect?: Inspect | null; + + eventsOverTime: MatrixOverTimeHistogramData[]; + + totalCount: number; +} + +export interface MatrixOverTimeHistogramData { + x: number; + + y: number; + + g: string; +} + export interface HostsData { edges: HostsEdges[]; @@ -1835,6 +1853,13 @@ export interface LastEventTimeSourceArgs { defaultIndex: string[]; } +export interface EventsOverTimeSourceArgs { + timerange: TimerangeInput; + + filterQuery?: string | null; + + defaultIndex: string[]; +} export interface HostsSourceArgs { id?: string | null; @@ -2416,6 +2441,58 @@ export namespace GetDomainsQuery { }; } +export namespace GetEventsOverTimeQuery { + export type Variables = { + sourceId: string; + timerange: TimerangeInput; + defaultIndex: string[]; + filterQuery?: string | null; + inspect: boolean; + }; + + export type Query = { + __typename?: 'Query'; + + source: Source; + }; + + export type Source = { + __typename?: 'Source'; + + id: string; + + EventsOverTime: EventsOverTime; + }; + + export type EventsOverTime = { + __typename?: 'EventsOverTimeData'; + + eventsOverTime: _EventsOverTime[]; + + totalCount: number; + + inspect?: Inspect | null; + }; + + export type _EventsOverTime = { + __typename?: 'MatrixOverTimeHistogramData'; + + x: number; + + y: number; + + g: string; + }; + + export type Inspect = { + __typename?: 'Inspect'; + + dsl: string[]; + + response: string[]; + }; +} + export namespace GetLastEventTimeQuery { export type Variables = { sourceId: string; diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/details/body.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/details/body.tsx index cc0c9253623ba..a115224dd24db 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/details/body.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/details/body.tsx @@ -55,6 +55,9 @@ const HostDetailsBodyComponent = React.memo( to: fromTo.to, }); }, + updateDateRange: (min: number, max: number) => { + setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); + }, })} ) : null diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx index 0b8087aff7f88..192b692253316 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx @@ -136,7 +136,6 @@ const HostDetailsComponent = React.memo( )} - ( to: fromTo.to, }); }, + updateDateRange: (min: number, max: number) => { + setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); + }, })} ) : null diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts_navigations.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts_navigations.tsx new file mode 100644 index 0000000000000..37283b7d4aa8e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts_navigations.tsx @@ -0,0 +1,387 @@ +/* + * 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 { StaticIndexPattern } from 'ui/index_patterns'; +import { getOr, omit } from 'lodash/fp'; +import React from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import * as i18n from './translations'; + +import { HostsTable, UncommonProcessTable } from '../../components/page/hosts'; + +import { HostsQuery } from '../../containers/hosts'; +import { AuthenticationTable } from '../../components/page/hosts/authentications_table'; +import { AnomaliesHostTable } from '../../components/ml/tables/anomalies_host_table'; +import { UncommonProcessesQuery } from '../../containers/uncommon_processes'; +import { InspectQuery, Refetch } from '../../store/inputs/model'; +import { NarrowDateRange } from '../../components/ml/types'; +import { hostsModel } from '../../store'; +import { manageQuery } from '../../components/page/manage_query'; +import { AuthenticationsQuery } from '../../containers/authentications'; +import { ESTermQuery } from '../../../common/typed_json'; +import { HostsTableType } from '../../store/hosts/model'; +import { StatefulEventsViewer } from '../../components/events_viewer'; +import { NavTab } from '../../components/navigation/types'; +import { EventsOverTimeQuery } from '../../containers/events/events_over_time'; +import { EventsOverTimeHistogram } from '../../components/page/hosts/events_over_time'; +import { UpdateDateRange } from '../../components/charts/common'; + +const getTabsOnHostsUrl = (tabName: HostsTableType) => `#/hosts/${tabName}`; +const getTabsOnHostDetailsUrl = (hostName: string, tabName: HostsTableType) => { + return `#/hosts/${hostName}/${tabName}`; +}; + +type KeyHostsNavTabWithoutMlPermission = HostsTableType.hosts & + HostsTableType.authentications & + HostsTableType.uncommonProcesses & + HostsTableType.events; + +type KeyHostsNavTabWithMlPermission = KeyHostsNavTabWithoutMlPermission & HostsTableType.anomalies; + +export type KeyHostsNavTab = KeyHostsNavTabWithoutMlPermission | KeyHostsNavTabWithMlPermission; + +type KeyHostDetailsNavTabWithoutMlPermission = HostsTableType.authentications & + HostsTableType.uncommonProcesses & + HostsTableType.events; + +type KeyHostDetailsNavTabWithMlPermission = KeyHostsNavTabWithoutMlPermission & + HostsTableType.anomalies; + +export type KeyHostDetailsNavTab = + | KeyHostDetailsNavTabWithoutMlPermission + | KeyHostDetailsNavTabWithMlPermission; + +export type HostsNavTab = Record; + +export const navTabsHosts = (hasMlUserPermissions: boolean): HostsNavTab => { + const hostsNavTabs = { + [HostsTableType.hosts]: { + id: HostsTableType.hosts, + name: i18n.NAVIGATION_ALL_HOSTS_TITLE, + href: getTabsOnHostsUrl(HostsTableType.hosts), + disabled: false, + urlKey: 'host', + }, + [HostsTableType.authentications]: { + id: HostsTableType.authentications, + name: i18n.NAVIGATION_AUTHENTICATIONS_TITLE, + href: getTabsOnHostsUrl(HostsTableType.authentications), + disabled: false, + urlKey: 'host', + }, + [HostsTableType.uncommonProcesses]: { + id: HostsTableType.uncommonProcesses, + name: i18n.NAVIGATION_UNCOMMON_PROCESSES_TITLE, + href: getTabsOnHostsUrl(HostsTableType.uncommonProcesses), + disabled: false, + urlKey: 'host', + }, + [HostsTableType.anomalies]: { + id: HostsTableType.anomalies, + name: i18n.NAVIGATION_ANOMALIES_TITLE, + href: getTabsOnHostsUrl(HostsTableType.anomalies), + disabled: false, + urlKey: 'host', + }, + [HostsTableType.events]: { + id: HostsTableType.events, + name: i18n.NAVIGATION_EVENTS_TITLE, + href: getTabsOnHostsUrl(HostsTableType.events), + disabled: false, + urlKey: 'host', + }, + }; + + return hasMlUserPermissions ? hostsNavTabs : omit([HostsTableType.anomalies], hostsNavTabs); +}; + +export const navTabsHostDetails = ( + hostName: string, + hasMlUserPermissions: boolean +): Record => { + const hostDetailsNavTabs = { + [HostsTableType.authentications]: { + id: HostsTableType.authentications, + name: i18n.NAVIGATION_AUTHENTICATIONS_TITLE, + href: getTabsOnHostDetailsUrl(hostName, HostsTableType.authentications), + disabled: false, + urlKey: 'host', + isDetailPage: true, + }, + [HostsTableType.uncommonProcesses]: { + id: HostsTableType.uncommonProcesses, + name: i18n.NAVIGATION_UNCOMMON_PROCESSES_TITLE, + href: getTabsOnHostDetailsUrl(hostName, HostsTableType.uncommonProcesses), + disabled: false, + urlKey: 'host', + isDetailPage: true, + }, + [HostsTableType.anomalies]: { + id: HostsTableType.anomalies, + name: i18n.NAVIGATION_ANOMALIES_TITLE, + href: getTabsOnHostDetailsUrl(hostName, HostsTableType.anomalies), + disabled: false, + urlKey: 'host', + isDetailPage: true, + }, + [HostsTableType.events]: { + id: HostsTableType.events, + name: i18n.NAVIGATION_EVENTS_TITLE, + href: getTabsOnHostDetailsUrl(hostName, HostsTableType.events), + disabled: false, + urlKey: 'host', + isDetailPage: true, + }, + }; + + return hasMlUserPermissions + ? hostDetailsNavTabs + : omit(HostsTableType.anomalies, hostDetailsNavTabs); +}; + +interface OwnProps { + type: hostsModel.HostsType; + startDate: number; + endDate: number; + filterQuery?: string | ESTermQuery; + kqlQueryExpression: string; +} +export type HostsComponentsQueryProps = OwnProps & { + deleteQuery?: ({ id }: { id: string }) => void; + indexPattern: StaticIndexPattern; + skip: boolean; + setQuery: ({ + id, + inspect, + loading, + refetch, + }: { + id: string; + inspect: InspectQuery | null; + loading: boolean; + refetch: Refetch; + }) => void; + updateDateRange?: UpdateDateRange; + filterQueryExpression?: string; + hostName?: string; +}; + +export type AnomaliesQueryTabBodyProps = OwnProps & { + skip: boolean; + narrowDateRange: NarrowDateRange; + hostName?: string; +}; + +const AuthenticationTableManage = manageQuery(AuthenticationTable); +const HostsTableManage = manageQuery(HostsTable); +const UncommonProcessTableManage = manageQuery(UncommonProcessTable); + +export const HostsQueryTabBody = ({ + deleteQuery, + endDate, + filterQuery, + indexPattern, + skip, + setQuery, + startDate, + type, +}: HostsComponentsQueryProps) => { + return ( + + {({ hosts, totalCount, loading, pageInfo, loadPage, id, inspect, isInspected, refetch }) => ( + + )} + + ); +}; + +export const AuthenticationsQueryTabBody = ({ + deleteQuery, + endDate, + filterQuery, + skip, + setQuery, + startDate, + type, +}: HostsComponentsQueryProps) => { + return ( + + {({ + authentications, + totalCount, + loading, + pageInfo, + loadPage, + id, + inspect, + isInspected, + refetch, + }) => { + return ( + + ); + }} + + ); +}; + +export const UncommonProcessTabBody = ({ + deleteQuery, + endDate, + filterQuery, + skip, + setQuery, + startDate, + type, +}: HostsComponentsQueryProps) => { + return ( + + {({ + uncommonProcesses, + totalCount, + loading, + pageInfo, + loadPage, + id, + inspect, + isInspected, + refetch, + }) => ( + + )} + + ); +}; + +export const AnomaliesTabBody = ({ + endDate, + skip, + startDate, + type, + narrowDateRange, + hostName, +}: AnomaliesQueryTabBodyProps) => { + return ( + + ); +}; +const EventsOverTimeManage = manageQuery(EventsOverTimeHistogram); + +export const EventsTabBody = ({ + endDate, + kqlQueryExpression, + startDate, + setQuery, + filterQuery, + updateDateRange = () => {}, +}: HostsComponentsQueryProps) => { + const HOSTS_PAGE_TIMELINE_ID = 'hosts-page'; + + return ( + <> + + {({ eventsOverTime, loading, id, inspect, refetch, totalCount }) => ( + + )} + + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/events_query_tab_body.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/events_query_tab_body.tsx index 928886cae3ad3..c7b7f1c0eb643 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/events_query_tab_body.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/events_query_tab_body.tsx @@ -5,22 +5,58 @@ */ import React from 'react'; +import { EuiSpacer } from '@elastic/eui'; import { StatefulEventsViewer } from '../../../components/events_viewer'; import { HostsComponentsQueryProps } from './types'; +import { manageQuery } from '../../../components/page/manage_query'; +import { EventsOverTimeHistogram } from '../../../components/page/hosts/events_over_time'; +import { EventsOverTimeQuery } from '../../../containers/events/events_over_time'; +import { hostsModel } from '../../../store/hosts'; const HOSTS_PAGE_TIMELINE_ID = 'hosts-page'; +const EventsOverTimeManage = manageQuery(EventsOverTimeHistogram); export const EventsQueryTabBody = ({ endDate, kqlQueryExpression, startDate, -}: HostsComponentsQueryProps) => ( - -); + setQuery, + filterQuery, + updateDateRange = () => {}, +}: HostsComponentsQueryProps) => { + return ( + <> + + {({ eventsOverTime, loading, id, inspect, refetch, totalCount }) => ( + + )} + + + + + ); +}; EventsQueryTabBody.displayName = 'EventsQueryTabBody'; diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/types.ts b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/types.ts index 7161772aac495..4554fa1351f5f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/types.ts @@ -12,6 +12,7 @@ import { InspectQuery, Refetch } from '../../../store/inputs/model'; import { HostsTableType } from '../../../store/hosts/model'; import { NavTab } from '../../../components/navigation/types'; +import { UpdateDateRange } from '../../../components/charts/common'; export type KeyHostsNavTabWithoutMlPermission = HostsTableType.hosts & HostsTableType.authentications & @@ -53,6 +54,7 @@ export type HostsComponentsQueryProps = QueryTabBodyProps & { loading: boolean; refetch: Refetch; }) => void; + updateDateRange?: UpdateDateRange; narrowDateRange?: NarrowDateRange; }; diff --git a/x-pack/legacy/plugins/siem/server/graphql/events/resolvers.ts b/x-pack/legacy/plugins/siem/server/graphql/events/resolvers.ts index 2e71399973e9f..09494594c7286 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/events/resolvers.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/events/resolvers.ts @@ -32,6 +32,11 @@ export interface EventsResolversDeps { events: Events; } +type QueryEventsOverTimeResolver = ChildResolverOf< + AppResolverOf, + QuerySourceResolver +>; + export const createEventsResolvers = ( libs: EventsResolversDeps ): { @@ -39,6 +44,7 @@ export const createEventsResolvers = ( Timeline: QueryTimelineResolver; TimelineDetails: QueryTimelineDetailsResolver; LastEventTime: QueryLastEventTimeResolver; + EventsOverTime: QueryEventsOverTimeResolver; }; } => ({ Source: { @@ -65,6 +71,13 @@ export const createEventsResolvers = ( }; return libs.events.getLastEventTimeData(req, options); }, + async EventsOverTime(source, args, { req }, info) { + const options = { + ...createOptions(source, args, info), + defaultIndex: args.defaultIndex, + }; + return libs.events.getEventsOverTime(req, options); + }, }, }); diff --git a/x-pack/legacy/plugins/siem/server/graphql/events/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/events/schema.gql.ts index 3b71977bc0d47..073fd60dbf1ed 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/events/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/events/schema.gql.ts @@ -68,6 +68,18 @@ export const eventsSchema = gql` network } + type MatrixOverTimeHistogramData { + x: Float! + y: Float! + g: String! + } + + type EventsOverTimeData { + inspect: Inspect + eventsOverTime: [MatrixOverTimeHistogramData!]! + totalCount: Float! + } + extend type Source { Timeline( pagination: PaginationInput! @@ -88,5 +100,10 @@ export const eventsSchema = gql` details: LastTimeDetails! defaultIndex: [String!]! ): LastEventTimeData! + EventsOverTime( + timerange: TimerangeInput! + filterQuery: String + defaultIndex: [String!]! + ): EventsOverTimeData! } `; diff --git a/x-pack/legacy/plugins/siem/server/graphql/types.ts b/x-pack/legacy/plugins/siem/server/graphql/types.ts index 87e6b3719a4bb..8fd80be5d04d0 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/types.ts @@ -146,6 +146,8 @@ export interface Source { TimelineDetails: TimelineDetailsData; LastEventTime: LastEventTimeData; + + EventsOverTime: EventsOverTimeData; /** Gets Hosts based on timerange and specified criteria, or all events in the timerange if no criteria is specified */ Hosts: HostsData; @@ -876,6 +878,22 @@ export interface LastEventTimeData { inspect?: Inspect | null; } +export interface EventsOverTimeData { + inspect?: Inspect | null; + + eventsOverTime: MatrixOverTimeHistogramData[]; + + totalCount: number; +} + +export interface MatrixOverTimeHistogramData { + x: number; + + y: number; + + g: string; +} + export interface HostsData { edges: HostsEdges[]; @@ -1864,6 +1882,13 @@ export interface LastEventTimeSourceArgs { defaultIndex: string[]; } +export interface EventsOverTimeSourceArgs { + timerange: TimerangeInput; + + filterQuery?: string | null; + + defaultIndex: string[]; +} export interface HostsSourceArgs { id?: string | null; @@ -2497,6 +2522,8 @@ export namespace SourceResolvers { TimelineDetails?: TimelineDetailsResolver; LastEventTime?: LastEventTimeResolver; + + EventsOverTime?: EventsOverTimeResolver; /** Gets Hosts based on timerange and specified criteria, or all events in the timerange if no criteria is specified */ Hosts?: HostsResolver; @@ -2609,6 +2636,19 @@ export namespace SourceResolvers { defaultIndex: string[]; } + export type EventsOverTimeResolver< + R = EventsOverTimeData, + Parent = Source, + Context = SiemContext + > = Resolver; + export interface EventsOverTimeArgs { + timerange: TimerangeInput; + + filterQuery?: string | null; + + defaultIndex: string[]; + } + export type HostsResolver = Resolver< R, Parent, @@ -5204,6 +5244,58 @@ export namespace LastEventTimeDataResolvers { > = Resolver; } +export namespace EventsOverTimeDataResolvers { + export interface Resolvers { + inspect?: InspectResolver; + + eventsOverTime?: EventsOverTimeResolver; + + totalCount?: TotalCountResolver; + } + + export type InspectResolver< + R = Inspect | null, + Parent = EventsOverTimeData, + Context = SiemContext + > = Resolver; + export type EventsOverTimeResolver< + R = MatrixOverTimeHistogramData[], + Parent = EventsOverTimeData, + Context = SiemContext + > = Resolver; + export type TotalCountResolver< + R = number, + Parent = EventsOverTimeData, + Context = SiemContext + > = Resolver; +} + +export namespace MatrixOverTimeHistogramDataResolvers { + export interface Resolvers { + x?: XResolver; + + y?: YResolver; + + g?: GResolver; + } + + export type XResolver< + R = number, + Parent = MatrixOverTimeHistogramData, + Context = SiemContext + > = Resolver; + export type YResolver< + R = number, + Parent = MatrixOverTimeHistogramData, + Context = SiemContext + > = Resolver; + export type GResolver< + R = string, + Parent = MatrixOverTimeHistogramData, + Context = SiemContext + > = Resolver; +} + export namespace HostsDataResolvers { export interface Resolvers { edges?: EdgesResolver; diff --git a/x-pack/legacy/plugins/siem/server/lib/events/elasticsearch_adapter.ts b/x-pack/legacy/plugins/siem/server/lib/events/elasticsearch_adapter.ts index 543fe0580b8a4..6dbb75d28149b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/events/elasticsearch_adapter.ts +++ b/x-pack/legacy/plugins/siem/server/lib/events/elasticsearch_adapter.ts @@ -25,12 +25,13 @@ import { TimelineData, TimelineDetailsData, TimelineEdges, + EventsOverTimeData, } from '../../graphql/types'; import { baseCategoryFields } from '../../utils/beat_schema/8.0.0'; import { reduceFields } from '../../utils/build_query/reduce_fields'; import { mergeFieldsWithHit, inspectStringifyObject } from '../../utils/build_query'; import { eventFieldsMap } from '../ecs_fields'; -import { FrameworkAdapter, FrameworkRequest } from '../framework'; +import { FrameworkAdapter, FrameworkRequest, RequestBasicOptions } from '../framework'; import { TermAggregation } from '../types'; import { buildDetailsQuery, buildTimelineQuery } from './query.dsl'; @@ -42,7 +43,10 @@ import { LastEventTimeRequestOptions, RequestDetailsOptions, TimelineRequestOptions, + EventsActionGroupData, } from './types'; +import { buildEventsOverTimeQuery } from './query.events_over_time.dsl'; +import { MatrixOverTimeHistogramData } from '../../../public/graphql/types'; export class ElasticsearchEventsAdapter implements EventsAdapter { constructor(private readonly framework: FrameworkAdapter) {} @@ -125,8 +129,65 @@ export class ElasticsearchEventsAdapter implements EventsAdapter { lastSeen: getOr(null, 'aggregations.last_seen_event.value_as_string', response), }; } + + public async getEventsOverTime( + request: FrameworkRequest, + options: RequestBasicOptions + ): Promise { + const dsl = buildEventsOverTimeQuery(options); + const response = await this.framework.callWithRequest( + request, + 'search', + dsl + ); + const totalCount = getOr(0, 'hits.total.value', response); + const eventsOverTimeBucket = getOr([], 'aggregations.eventActionGroup.buckets', response); + const inspect = { + dsl: [inspectStringifyObject(dsl)], + response: [inspectStringifyObject(response)], + }; + return { + inspect, + eventsOverTime: getEventsOverTimeByActionName(eventsOverTimeBucket), + totalCount, + }; + } } +/** + * Not in use at the moment, + * reserved this parser for next feature of switchign between total events and grouped events + */ +export const getTotalEventsOverTime = ( + data: EventsActionGroupData[] +): MatrixOverTimeHistogramData[] => { + return data && data.length > 0 + ? data.map(({ key, doc_count }) => ({ + x: key, + y: doc_count, + g: 'total events', + })) + : []; +}; + +const getEventsOverTimeByActionName = ( + data: EventsActionGroupData[] +): MatrixOverTimeHistogramData[] => { + let result: MatrixOverTimeHistogramData[] = []; + data.forEach(({ key: group, events }) => { + const eventsData = getOr([], 'buckets', events).map( + ({ key, doc_count }: { key: number; doc_count: number }) => ({ + x: key, + y: doc_count, + g: group, + }) + ); + result = [...result, ...eventsData]; + }); + + return result; +}; + export const formatEventsData = ( fields: readonly string[], hit: EventHit, diff --git a/x-pack/legacy/plugins/siem/server/lib/events/index.ts b/x-pack/legacy/plugins/siem/server/lib/events/index.ts index 9c1f87aa3d8bf..9e2457904f8c0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/events/index.ts +++ b/x-pack/legacy/plugins/siem/server/lib/events/index.ts @@ -5,7 +5,7 @@ */ import { LastEventTimeData, TimelineData, TimelineDetailsData } from '../../graphql/types'; -import { FrameworkRequest } from '../framework'; +import { FrameworkRequest, RequestBasicOptions } from '../framework'; export * from './elasticsearch_adapter'; import { EventsAdapter, @@ -13,6 +13,7 @@ import { LastEventTimeRequestOptions, RequestDetailsOptions, } from './types'; +import { EventsOverTimeData } from '../../../public/graphql/types'; export class Events { constructor(private readonly adapter: EventsAdapter) {} @@ -37,4 +38,11 @@ export class Events { ): Promise { return this.adapter.getLastEventTimeData(req, options); } + + public async getEventsOverTime( + req: FrameworkRequest, + options: RequestBasicOptions + ): Promise { + return this.adapter.getEventsOverTime(req, options); + } } diff --git a/x-pack/legacy/plugins/siem/server/lib/events/query.events_over_time.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/events/query.events_over_time.dsl.ts new file mode 100644 index 0000000000000..e655485638e16 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/events/query.events_over_time.dsl.ts @@ -0,0 +1,79 @@ +/* + * 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 { createQueryFilterClauses, calculateTimeseriesInterval } from '../../utils/build_query'; +import { RequestBasicOptions } from '../framework'; + +export const buildEventsOverTimeQuery = ({ + filterQuery, + timerange: { from, to }, + defaultIndex, + sourceConfiguration: { + fields: { timestamp }, + }, +}: RequestBasicOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + [timestamp]: { + gte: from, + lte: to, + }, + }, + }, + ]; + + const getHistogramAggregation = () => { + const minIntervalSeconds = 10; + const interval = calculateTimeseriesInterval(from, to, minIntervalSeconds); + const histogramTimestampField = '@timestamp'; + const dateHistogram = { + date_histogram: { + field: histogramTimestampField, + fixed_interval: `${interval}s`, + }, + }; + const autoDateHistogram = { + auto_date_histogram: { + field: histogramTimestampField, + buckets: 36, + }, + }; + return { + eventActionGroup: { + terms: { + field: 'event.action', + missing: 'All others', + order: { + _count: 'desc', + }, + size: 10, + }, + aggs: { + events: interval ? dateHistogram : autoDateHistogram, + }, + }, + }; + }; + + const dslQuery = { + index: defaultIndex, + allowNoIndices: true, + ignoreUnavailable: true, + body: { + aggregations: getHistogramAggregation(), + query: { + bool: { + filter, + }, + }, + size: 0, + track_total_hits: true, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/events/types.ts b/x-pack/legacy/plugins/siem/server/lib/events/types.ts index 30e49b8a37cc7..2da0ff13638e1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/events/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/events/types.ts @@ -11,8 +11,14 @@ import { SourceConfiguration, TimelineData, TimelineDetailsData, + EventsOverTimeData, } from '../../graphql/types'; -import { FrameworkRequest, RequestOptions, RequestOptionsPaginated } from '../framework'; +import { + FrameworkRequest, + RequestOptions, + RequestOptionsPaginated, + RequestBasicOptions, +} from '../framework'; import { SearchHit } from '../types'; export interface EventsAdapter { @@ -25,6 +31,10 @@ export interface EventsAdapter { req: FrameworkRequest, options: LastEventTimeRequestOptions ): Promise; + getEventsOverTime( + req: FrameworkRequest, + options: RequestBasicOptions + ): Promise; } export interface TimelineRequestOptions extends RequestOptions { @@ -77,3 +87,17 @@ export interface RequestDetailsOptions { eventId: string; defaultIndex: string[]; } + +interface EventsOverTimeHistogramData { + key_as_string: string; + key: number; + doc_count: number; +} + +export interface EventsActionGroupData { + key: number; + events: { + bucket: EventsOverTimeHistogramData[]; + }; + doc_count: number; +} diff --git a/x-pack/legacy/plugins/siem/server/utils/build_query/calculate_timeseries_interval.ts b/x-pack/legacy/plugins/siem/server/utils/build_query/calculate_timeseries_interval.ts new file mode 100644 index 0000000000000..3eaaa6c30a4fa --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/utils/build_query/calculate_timeseries_interval.ts @@ -0,0 +1,102 @@ +/* + * 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. + */ +/* + ** Applying the same logic as: + ** x-pack/legacy/plugins/apm/server/lib/helpers/get_bucket_size/calculate_auto.js + */ +import moment from 'moment'; +import { get } from 'lodash/fp'; +const d = moment.duration; + +const roundingRules = [ + [d(500, 'ms'), d(100, 'ms')], + [d(5, 'second'), d(1, 'second')], + [d(7.5, 'second'), d(5, 'second')], + [d(15, 'second'), d(10, 'second')], + [d(45, 'second'), d(30, 'second')], + [d(3, 'minute'), d(1, 'minute')], + [d(9, 'minute'), d(5, 'minute')], + [d(20, 'minute'), d(10, 'minute')], + [d(45, 'minute'), d(30, 'minute')], + [d(2, 'hour'), d(1, 'hour')], + [d(6, 'hour'), d(3, 'hour')], + [d(24, 'hour'), d(12, 'hour')], + [d(1, 'week'), d(1, 'd')], + [d(3, 'week'), d(1, 'week')], + [d(1, 'year'), d(1, 'month')], + [Infinity, d(1, 'year')], +]; + +const revRoundingRules = roundingRules.slice(0).reverse(); + +const find = ( + rules: Array>, + check: ( + bound: number | moment.Duration, + interval: number | moment.Duration, + target: number + ) => number | moment.Duration | undefined, + last?: boolean +): ((buckets: number, duration: number | moment.Duration) => moment.Duration | undefined) => { + const pick = (buckets: number, duration: number | moment.Duration): number | moment.Duration => { + const target = + typeof duration === 'number' ? duration / buckets : duration.asMilliseconds() / buckets; + let lastResp = null; + + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + const resp = check(rule[0], rule[1], target); + + if (resp == null) { + if (last) { + if (lastResp) return lastResp; + break; + } + } + + if (!last && resp) return resp; + lastResp = resp; + } + + // fallback to just a number of milliseconds, ensure ms is >= 1 + const ms = Math.max(Math.floor(target), 1); + return moment.duration(ms, 'ms'); + }; + + return (buckets, duration) => { + const interval = pick(buckets, duration); + const intervalData = get('_data', interval); + if (intervalData) return moment.duration(intervalData); + }; +}; + +export const calculateAuto = { + near: find( + revRoundingRules, + (bound, interval, target) => { + if (bound > target) return interval; + }, + true + ), + lessThan: find(revRoundingRules, (_bound, interval, target) => { + if (interval < target) return interval; + }), + atLeast: find(revRoundingRules, (_bound, interval, target) => { + if (interval <= target) return interval; + }), +}; + +export const calculateTimeseriesInterval = ( + lowerBoundInMsSinceEpoch: number, + upperBoundInMsSinceEpoch: number, + minIntervalSeconds: number +) => { + const duration = moment.duration(upperBoundInMsSinceEpoch - lowerBoundInMsSinceEpoch, 'ms'); + + const matchedInterval = calculateAuto.near(50, duration); + + return matchedInterval ? Math.max(matchedInterval.asSeconds(), 1) : null; +}; diff --git a/x-pack/legacy/plugins/siem/server/utils/build_query/index.ts b/x-pack/legacy/plugins/siem/server/utils/build_query/index.ts index be247d28aaaf8..c97e78aad2b69 100644 --- a/x-pack/legacy/plugins/siem/server/utils/build_query/index.ts +++ b/x-pack/legacy/plugins/siem/server/utils/build_query/index.ts @@ -7,6 +7,7 @@ export * from './fields'; export * from './filters'; export * from './merge_fields_with_hits'; +export * from './calculate_timeseries_interval'; export const assertUnreachable = ( x: never, diff --git a/x-pack/test/api_integration/apis/siem/events_over_time.ts b/x-pack/test/api_integration/apis/siem/events_over_time.ts new file mode 100644 index 0000000000000..10b81734b7b79 --- /dev/null +++ b/x-pack/test/api_integration/apis/siem/events_over_time.ts @@ -0,0 +1,98 @@ +/* + * 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 expect from '@kbn/expect'; +import { EventsOverTimeGqlQuery } from '../../../../legacy/plugins/siem/public/containers/events/events_over_time/events_over_time.gql_query'; +import { GetEventsOverTimeQuery } from '../../../../legacy/plugins/siem/public/graphql/types'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const client = getService('siemGraphQLClient'); + const FROM = new Date('2000-01-01T00:00:00.000Z').valueOf(); + const TO = new Date('3000-01-01T00:00:00.000Z').valueOf(); + describe('Events over time', () => { + describe('With filebeat', () => { + before(() => esArchiver.load('filebeat/default')); + after(() => esArchiver.unload('filebeat/default')); + + it('Make sure that we get events over time data', () => { + return client + .query({ + query: EventsOverTimeGqlQuery, + variables: { + sourceId: 'default', + timerange: { + interval: '12h', + to: TO, + from: FROM, + }, + defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + inspect: false, + }, + }) + .then(resp => { + const expectedData = [ + { + x: new Date('2018-12-20T00:00:00.000Z').valueOf(), + y: 4884, + g: 'All others', + __typename: 'MatrixOverTimeHistogramData', + }, + { + x: new Date('2018-12-20T00:00:00.000Z').valueOf(), + y: 1273, + g: 'netflow_flow', + __typename: 'MatrixOverTimeHistogramData', + }, + ]; + const eventsOverTime = resp.data.source.EventsOverTime; + expect(eventsOverTime.eventsOverTime).to.eql(expectedData); + }); + }); + }); + + describe('With packetbeat', () => { + before(() => esArchiver.load('packetbeat/default')); + after(() => esArchiver.unload('packetbeat/default')); + + it('Make sure that we get events over time data', () => { + return client + .query({ + query: EventsOverTimeGqlQuery, + variables: { + sourceId: 'default', + timerange: { + interval: '12h', + to: TO, + from: FROM, + }, + defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + inspect: false, + }, + }) + .then(resp => { + const expectedData = [ + { + x: new Date('2018-12-20T00:00:00.000Z').valueOf(), + y: 4884, + g: 'All others', + __typename: 'MatrixOverTimeHistogramData', + }, + { + x: new Date('2018-12-20T00:00:00.000Z').valueOf(), + y: 1273, + g: 'netflow_flow', + __typename: 'MatrixOverTimeHistogramData', + }, + ]; + const eventsOverTime = resp.data.source.EventsOverTime; + expect(eventsOverTime.eventsOverTime).to.eql(expectedData); + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/siem/index.js b/x-pack/test/api_integration/apis/siem/index.js index 8213fcb85a297..a28c2f42a52df 100644 --- a/x-pack/test/api_integration/apis/siem/index.js +++ b/x-pack/test/api_integration/apis/siem/index.js @@ -8,6 +8,7 @@ export default function ({ loadTestFile }) { describe('Siem GraphQL Endpoints', () => { loadTestFile(require.resolve('./authentications')); loadTestFile(require.resolve('./domains')); + loadTestFile(require.resolve('./events_over_time')); loadTestFile(require.resolve('./hosts')); loadTestFile(require.resolve('./kpi_network')); loadTestFile(require.resolve('./kpi_hosts'));