[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
This commit is contained in:
Angela Chuang 2019-09-25 06:07:11 +01:00 committed by GitHub
parent 4ec27fb691
commit c878857d36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 2027 additions and 406 deletions

View file

@ -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(
<AreaChartBaseComponent height={100} width={120} data={mockAreaChartData} />
<AreaChartBaseComponent
height={customHeight}
width={customWidth}
data={mockAreaChartData}
/>
);
});
@ -65,8 +70,8 @@ describe('AreaChartBaseComponent', () => {
beforeAll(() => {
shallowWrapper = shallow(
<AreaChartBaseComponent
height={100}
width={120}
height={customHeight}
width={customWidth}
data={mockAreaChartData}
configs={configs}
/>
@ -118,7 +123,11 @@ describe('AreaChartBaseComponent', () => {
describe('render with default configs if no customized configs given', () => {
beforeAll(() => {
shallowWrapper = shallow(
<AreaChartBaseComponent height={100} width={120} data={mockAreaChartData} />
<AreaChartBaseComponent
height={customHeight}
width={customWidth}
data={mockAreaChartData}
/>
);
});
@ -220,9 +229,11 @@ describe('AreaChartWithCustomPrompt', () => {
],
],
],
])('renders areachart', (data: ChartConfigsData[] | [] | null | undefined) => {
])('renders areachart', (data: ChartSeriesData[] | [] | null | undefined) => {
beforeAll(() => {
shallowWrapper = shallow(<AreaChartWithCustomPrompt height={100} width={120} data={data} />);
shallowWrapper = shallow(
<AreaChartWithCustomPrompt height={customHeight} width={customWidth} data={data} />
);
});
it('render AreaChartBaseComponent', () => {
@ -310,9 +321,11 @@ describe('AreaChartWithCustomPrompt', () => {
},
],
],
])('renders prompt', (data: ChartConfigsData[] | [] | null | undefined) => {
])('renders prompt', (data: ChartSeriesData[] | [] | null | undefined) => {
beforeAll(() => {
shallowWrapper = shallow(<AreaChartWithCustomPrompt height={100} width={120} data={data} />);
shallowWrapper = shallow(
<AreaChartWithCustomPrompt height={customHeight} width={customWidth} data={data} />
);
});
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(<AreaChart configs={mockConfig} areaChart={mockData} />);
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(<AreaChart configs={mockConfig} areaChart={mockData} />);
expect(shallowWrapper.find('AutoSizer')).toHaveLength(0);
expect(shallowWrapper.find('ChartHolder')).toHaveLength(1);
});
});

View file

@ -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<AreaSeriesStyle> => {
// 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 ? (
<div style={{ height: chartConfigs.height, width: chartConfigs.width, position: 'relative' }}>
<Chart>
<Settings {...settings} theme={getTheme()} />
<Settings {...settings} />
{data.map(series => {
const seriesKey = series.key;
const seriesSpecId = getSpecId(seriesKey);
@ -89,23 +90,15 @@ export const AreaChartBaseComponent = React.memo<{
) : null;
})}
{xTickFormatter ? (
<Axis
id={xAxisId}
position={Position.Bottom}
showOverlappingTicks={false}
tickFormat={xTickFormatter}
tickSize={0}
/>
) : (
<Axis id={xAxisId} position={Position.Bottom} showOverlappingTicks={false} tickSize={0} />
)}
<Axis
id={xAxisId}
position={Position.Bottom}
showOverlappingTicks={false}
tickFormat={xTickFormatter}
tickSize={0}
/>
{yTickFormatter ? (
<Axis id={yAxisId} position={Position.Left} tickSize={0} tickFormat={yTickFormatter} />
) : (
<Axis id={yAxisId} position={Position.Left} tickSize={0} />
)}
<Axis id={yAxisId} position={Position.Left} tickSize={0} tickFormat={yTickFormatter} />
</Chart>
</div>
) : 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<{
) ? (
<AreaChartBaseComponent height={height} width={width} data={data} configs={configs} />
) : (
<ChartHolder />
<ChartHolder height={height} width={width} />
);
});
AreaChartWithCustomPrompt.displayName = 'AreaChartWithCustomPrompt';
export const AreaChart = React.memo<{
areaChart: ChartConfigsData[] | null | undefined;
areaChart: ChartSeriesData[] | null | undefined;
configs?: ChartSeriesConfigs | undefined;
}>(({ areaChart, configs }) => (
<AutoSizer detectAnyWindowResize={false} content>
{({ measureRef, content: { height, width } }) => (
<WrappedByAutoSizer innerRef={measureRef}>
<AreaChartWithCustomPrompt
data={areaChart}
height={height}
width={width}
configs={configs}
/>
</WrappedByAutoSizer>
)}
</AutoSizer>
));
}>(({ areaChart, configs }) => {
const customHeight = get('customHeight', configs);
const customWidth = get('customWidth', configs);
return get(`0.value.length`, areaChart) ? (
<AutoSizer detectAnyWindowResize={false} content>
{({ measureRef, content: { height, width } }) => (
<WrappedByAutoSizer innerRef={measureRef} height={getChartHeight(customHeight, height)}>
<AreaChartWithCustomPrompt
data={areaChart}
height={getChartHeight(customHeight, height)}
width={getChartWidth(customWidth, width)}
configs={configs}
/>
</WrappedByAutoSizer>
)}
</AutoSizer>
) : (
<ChartHolder height={getChartHeight(customHeight)} width={getChartWidth(customWidth)} />
);
});
AreaChart.displayName = 'AreaChart';

View file

@ -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(
<BarChartBaseComponent height={100} width={120} data={mockBarChartData} />
<BarChartBaseComponent height={customHeight} width={customWidth} data={mockBarChartData} />
);
});
@ -54,7 +55,12 @@ describe('BarChartBaseComponent', () => {
beforeAll(() => {
shallowWrapper = shallow(
<BarChartBaseComponent height={100} width={120} data={mockBarChartData} configs={configs} />
<BarChartBaseComponent
height={customHeight}
width={customWidth}
data={mockBarChartData}
configs={configs}
/>
);
});
@ -103,7 +109,7 @@ describe('BarChartBaseComponent', () => {
describe('render with default configs if no customized configs given', () => {
beforeAll(() => {
shallowWrapper = shallow(
<BarChartBaseComponent height={100} width={120} data={mockBarChartData} />
<BarChartBaseComponent height={customHeight} width={customWidth} data={mockBarChartData} />
);
});
@ -198,7 +204,11 @@ describe.each([
describe('renders barchart', () => {
beforeAll(() => {
shallowWrapper = shallow(
<BarChartWithCustomPrompt height={100} width={120} data={mockBarChartData} />
<BarChartWithCustomPrompt
height={customHeight}
width={customWidth}
data={mockBarChartData}
/>
);
});
@ -271,10 +281,12 @@ describe.each([
},
],
],
])('renders prompt', (data: ChartConfigsData[] | [] | null | undefined) => {
])('renders prompt', (data: ChartSeriesData[] | [] | null | undefined) => {
let shallowWrapper: ShallowWrapper;
beforeAll(() => {
shallowWrapper = shallow(<BarChartWithCustomPrompt height={100} width={120} data={data} />);
shallowWrapper = shallow(
<BarChartWithCustomPrompt height={customHeight} width={customWidth} data={data} />
);
});
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(<BarChart configs={mockConfig} barChart={mockData} />);
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(<BarChart configs={mockConfig} barChart={mockData} />);
expect(shallowWrapper.find('AutoSizer')).toHaveLength(0);
expect(shallowWrapper.find('ChartHolder')).toHaveLength(1);
});
});

View file

@ -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 ? (
<Chart>
<Settings {...settings} theme={getTheme()} />
<Settings {...settings} />
{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 ? (
<Axis
id={xAxisId}
position={Position.Bottom}
showOverlappingTicks={false}
tickSize={0}
tickFormat={xTickFormatter}
/>
) : (
<Axis id={xAxisId} position={Position.Bottom} showOverlappingTicks={false} tickSize={0} />
)}
<Axis
id={xAxisId}
position={Position.Bottom}
showOverlappingTicks={false}
tickSize={tickSize}
tickFormat={xTickFormatter}
/>
{yTickFormatter ? (
<Axis id={yAxisId} position={Position.Left} tickSize={0} tickFormat={yTickFormatter} />
) : (
<Axis id={yAxisId} position={Position.Left} tickSize={0} />
)}
<Axis id={yAxisId} position={Position.Left} tickSize={tickSize} tickFormat={yTickFormatter} />
</Chart>
) : 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)
) ? (
<BarChartBaseComponent height={height} width={width} data={data} configs={configs} />
) : (
<ChartHolder />
<ChartHolder height={height} width={width} />
);
});
BarChartWithCustomPrompt.displayName = 'BarChartWithCustomPrompt';
export const BarChart = React.memo<{
barChart: ChartConfigsData[] | null | undefined;
barChart: ChartSeriesData[] | null | undefined;
configs?: ChartSeriesConfigs | undefined;
}>(({ barChart, configs }) => (
<AutoSizer detectAnyWindowResize={false} content>
{({ measureRef, content: { height, width } }) => (
<WrappedByAutoSizer innerRef={measureRef}>
<BarChartWithCustomPrompt height={height} width={width} data={barChart} configs={configs} />
</WrappedByAutoSizer>
)}
</AutoSizer>
));
}>(({ barChart, configs }) => {
const customHeight = get('customHeight', configs);
const customWidth = get('customWidth', configs);
return get(`0.value.length`, barChart) ? (
<AutoSizer detectAnyWindowResize={false} content>
{({ measureRef, content: { height, width } }) => (
<WrappedByAutoSizer innerRef={measureRef} height={getChartHeight(customHeight, height)}>
<BarChartWithCustomPrompt
height={getChartHeight(customHeight, height)}
width={getChartWidth(customWidth, width)}
data={barChart}
configs={configs}
/>
</WrappedByAutoSizer>
)}
</AutoSizer>
) : (
<ChartHolder height={getChartHeight(customHeight)} width={getChartWidth(customWidth)} />
);
});
BarChart.displayName = 'BarChart';

View file

@ -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(<ChartHolder />);
expect(shallowWrapper.props()).toMatchObject({
height,
width,
});
});
it('should render with given props', () => {
const height = `100px`;
const width = `100px`;
shallowWrapper = shallow(<ChartHolder height={height} width={width} />);
expect(shallowWrapper.props()).toMatchObject({
height,
width,
});
});
});
describe('WrappedByAutoSizer', () => {
it('should render correct default height', () => {
const wrapper = shallow(<WrappedByAutoSizer />);
expect(wrapper).toHaveStyleRule('height', defaultChartHeight);
});
it('should render correct given height', () => {
const wrapper = shallow(<WrappedByAutoSizer height="100px" />);
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);
});
});

View file

@ -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 = () => (
<FlexGroup justifyContent="center" alignItems="center">
export const ChartHolder = ({
height = '100%',
width = '100%',
}: {
height?: string | null;
width?: string | null;
}) => (
<FlexGroup justifyContent="center" alignItems="center" height={height} width={width}>
<EuiFlexItem grow={false}>
<EuiText size="s" textAlign="center" color="subdued">
{i18n.translate('xpack.siem.chart.dataNotAvailableTitle', {
@ -48,15 +55,6 @@ export const ChartHolder = () => (
</FlexGroup>
);
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<SettingSpecProps>;
}
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;
};

View file

@ -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: () => <div className="euiLoadingContent"></div>,
};
});
jest.mock('../loader', () => {
return {
Loader: () => <div className="loader"></div>,
};
});
jest.mock('../../lib/settings/use_kibana_ui_setting', () => {
return { useKibanaUiSetting: () => [false] };
});
jest.mock('../header_panel', () => {
return {
HeaderPanel: () => <div className="headerPanel"></div>,
};
});
jest.mock('../charts/barchart', () => {
return {
BarChart: () => <div className="barchart"></div>,
};
});
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(<MatrixOverTimeHistogram {...mockMatrixOverTimeHistogramProps} />);
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(<MatrixOverTimeHistogram {...mockProps} />);
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(<MatrixOverTimeHistogram {...mockProps} />);
expect(wrapper.find(`.barchart`)).toBeTruthy();
});
});
});

View file

@ -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 (
<Panel
data-test-subj={`${dataKey}Panel`}
loading={loading}
onMouseEnter={() => setShowInspect(true)}
onMouseLeave={() => setShowInspect(false)}
>
<HeaderPanel
id={id}
title={title}
showInspect={!loadingInitial && showInspect}
subtitle={!loadingInitial && subtitle}
/>
{loadingInitial ? (
<EuiLoadingContent data-test-subj="initialLoadingPanelMatrixOverTime" lines={10} />
) : (
<>
<BarChart barChart={barChartData} configs={barchartConfigs} />
{loading && (
<Loader
overlay
overlayBackground={
darkMode ? darkTheme.euiPageBackgroundColor : lightTheme.euiPageBackgroundColor
}
size="xl"
/>
)}
</>
)}
</Panel>
);
};

View file

@ -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 (
<MatrixOverTimeHistogram
title={i18n.EVENT_COUNT_FREQUENCY_BY_ACTION}
subtitle={subtitle}
dataKey={dataKey}
{...matrixOverTimeProps}
/>
);
};

View file

@ -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}}`,
});

View file

@ -38,11 +38,11 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] =
data-test-subj="stat-item"
>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
data-test-subj="stat-item"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
data-test-subj="stat-item"
>
<EuiPanel
@ -118,7 +118,7 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] =
showInspect={false}
>
<div
className="sc-EHOje wlqEL"
className="sc-bxivhb fEneUz"
data-test-subj="transparent-inspect-container"
>
<EuiButtonIcon
@ -188,10 +188,10 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] =
key="stat-items-field-hosts"
>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
>
<EuiFlexGroup
alignItems="center"
@ -203,22 +203,22 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] =
>
<FlexItem>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
>
<StatValue>
<EuiTitle
className="sc-gzVnrw dJnFxB"
className="sc-EHOje koezUx"
>
<p
className="euiTitle euiTitle--medium sc-gzVnrw dJnFxB"
className="euiTitle euiTitle--medium sc-EHOje koezUx"
data-test-subj="stat-title"
>
<EmptyWrapper>
<span
className="sc-htpNat bijuWJ"
className="sc-bdVaJa cSuzWb"
>
</span>
@ -292,11 +292,11 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] =
data-test-subj="stat-item"
>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
data-test-subj="stat-item"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
data-test-subj="stat-item"
>
<EuiPanel
@ -372,7 +372,7 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] =
showInspect={false}
>
<div
className="sc-EHOje wlqEL"
className="sc-bxivhb fEneUz"
data-test-subj="transparent-inspect-container"
>
<EuiButtonIcon
@ -442,10 +442,10 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] =
key="stat-items-field-hosts"
>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
>
<EuiFlexGroup
alignItems="center"
@ -458,22 +458,22 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] =
0
<FlexItem>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
>
<StatValue>
<EuiTitle
className="sc-gzVnrw dJnFxB"
className="sc-EHOje koezUx"
>
<p
className="euiTitle euiTitle--medium sc-gzVnrw dJnFxB"
className="euiTitle euiTitle--medium sc-EHOje koezUx"
data-test-subj="stat-title"
>
<EmptyWrapper>
<span
className="sc-htpNat bijuWJ"
className="sc-bdVaJa cSuzWb"
>
</span>
@ -616,11 +616,11 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
data-test-subj="stat-item"
>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
data-test-subj="stat-item"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
data-test-subj="stat-item"
>
<EuiPanel
@ -696,7 +696,7 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
showInspect={false}
>
<div
className="sc-EHOje dUUqHB"
className="sc-bxivhb fGGQew"
data-test-subj="transparent-inspect-container"
>
<EuiButtonIcon
@ -766,10 +766,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
key="stat-items-field-uniqueSourceIps"
>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
>
<EuiFlexGroup
alignItems="center"
@ -783,11 +783,11 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
grow={false}
>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero sc-bZQynM kpuYFd"
className="euiFlexItem euiFlexItem--flexGrowZero sc-ifAKCX cFdETZ"
>
<EuiIcon
color="#DB1374"
@ -826,17 +826,17 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
</FlexItem>
<FlexItem>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
>
<StatValue>
<EuiTitle
className="sc-gzVnrw dJnFxB"
className="sc-EHOje koezUx"
>
<p
className="euiTitle euiTitle--medium sc-gzVnrw dJnFxB"
className="euiTitle euiTitle--medium sc-EHOje koezUx"
data-test-subj="stat-title"
>
1,714
@ -857,10 +857,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
key="stat-items-field-uniqueDestinationIps"
>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
>
<EuiFlexGroup
alignItems="center"
@ -874,11 +874,11 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
grow={false}
>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero sc-bZQynM kpuYFd"
className="euiFlexItem euiFlexItem--flexGrowZero sc-ifAKCX cFdETZ"
>
<EuiIcon
color="#490092"
@ -917,17 +917,17 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
</FlexItem>
<FlexItem>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
>
<StatValue>
<EuiTitle
className="sc-gzVnrw dJnFxB"
className="sc-EHOje koezUx"
>
<p
className="euiTitle euiTitle--medium sc-gzVnrw dJnFxB"
className="euiTitle euiTitle--medium sc-EHOje koezUx"
data-test-subj="stat-title"
>
2,359
@ -957,10 +957,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
>
<FlexItem>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
>
<BarChart
barChart={
@ -992,6 +992,7 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
"axis": Object {
"xTickFormatter": [Function],
},
"customHeight": 74,
"series": Object {
"xScaleType": "ordinal",
"yScaleType": "linear",
@ -1003,112 +1004,19 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
}
}
>
<AutoSizer
content={true}
detectAnyWindowResize={false}
>
<WrappedByAutoSizer
innerRef={[Function]}
>
<div
className="sc-bwzfXH ffMqh"
>
<BarChartWithCustomPrompt
configs={
Object {
"axis": Object {
"xTickFormatter": [Function],
},
"series": Object {
"xScaleType": "ordinal",
"yScaleType": "linear",
},
"settings": Object {
"onElementClick": [Function],
"rotation": 90,
},
}
}
data={
Array [
Object {
"color": "#DB1374",
"key": "uniqueSourceIps",
"value": Array [
Object {
"x": "uniqueSourceIps",
"y": "1714",
},
],
},
Object {
"color": "#490092",
"key": "uniqueDestinationIps",
"value": Array [
Object {
"x": "uniqueDestinationIps",
"y": 2354,
},
],
},
]
}
>
<BarChartBaseComponent
configs={
Object {
"axis": Object {
"xTickFormatter": [Function],
},
"series": Object {
"xScaleType": "ordinal",
"yScaleType": "linear",
},
"settings": Object {
"onElementClick": [Function],
"rotation": 90,
},
}
}
data={
Array [
Object {
"color": "#DB1374",
"key": "uniqueSourceIps",
"value": Array [
Object {
"x": "uniqueSourceIps",
"y": "1714",
},
],
},
Object {
"color": "#490092",
"key": "uniqueDestinationIps",
"value": Array [
Object {
"x": "uniqueDestinationIps",
"y": 2354,
},
],
},
]
}
/>
</BarChartWithCustomPrompt>
</div>
</WrappedByAutoSizer>
</AutoSizer>
<div
className="barchart"
/>
</BarChart>
</div>
</EuiFlexItem>
</FlexItem>
<FlexItem>
<EuiFlexItem
className="sc-bZQynM kpuYFd"
className="sc-ifAKCX cFdETZ"
>
<div
className="euiFlexItem sc-bZQynM kpuYFd"
className="euiFlexItem sc-ifAKCX cFdETZ"
>
<AreaChart
areaChart={
@ -1157,6 +1065,7 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
"xTickFormatter": [Function],
"yTickFormatter": [Function],
},
"customHeight": 74,
"series": Object {
"xScaleType": "time",
"yScaleType": "linear",
@ -1167,134 +1076,9 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
}
}
>
<AutoSizer
content={true}
detectAnyWindowResize={false}
>
<WrappedByAutoSizer
innerRef={[Function]}
>
<div
className="sc-bwzfXH ffMqh"
>
<AreaChartWithCustomPrompt
configs={
Object {
"axis": Object {
"xTickFormatter": [Function],
"yTickFormatter": [Function],
},
"series": Object {
"xScaleType": "time",
"yScaleType": "linear",
},
"settings": Object {
"onBrushEnd": [MockFunction],
},
}
}
data={
Array [
Object {
"color": "#DB1374",
"key": "uniqueSourceIpsHistogram",
"value": Array [
Object {
"x": 1556888400000,
"y": 565975,
},
Object {
"x": 1556931600000,
"y": 1084366,
},
Object {
"x": 1556974800000,
"y": 12280,
},
],
},
Object {
"color": "#490092",
"key": "uniqueDestinationIpsHistogram",
"value": Array [
Object {
"x": 1556888400000,
"y": 565975,
},
Object {
"x": 1556931600000,
"y": 1084366,
},
Object {
"x": 1556974800000,
"y": 12280,
},
],
},
]
}
>
<AreaChartBaseComponent
configs={
Object {
"axis": Object {
"xTickFormatter": [Function],
"yTickFormatter": [Function],
},
"series": Object {
"xScaleType": "time",
"yScaleType": "linear",
},
"settings": Object {
"onBrushEnd": [MockFunction],
},
}
}
data={
Array [
Object {
"color": "#DB1374",
"key": "uniqueSourceIpsHistogram",
"value": Array [
Object {
"x": 1556888400000,
"y": 565975,
},
Object {
"x": 1556931600000,
"y": 1084366,
},
Object {
"x": 1556974800000,
"y": 12280,
},
],
},
Object {
"color": "#490092",
"key": "uniqueDestinationIpsHistogram",
"value": Array [
Object {
"x": 1556888400000,
"y": 565975,
},
Object {
"x": 1556931600000,
"y": 1084366,
},
Object {
"x": 1556974800000,
"y": 12280,
},
],
},
]
}
/>
</AreaChartWithCustomPrompt>
</div>
</WrappedByAutoSizer>
</AutoSizer>
<div
className="areachart"
/>
</AreaChart>
</div>
</EuiFlexItem>

View file

@ -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: () => <div className="areachart"></div> };
});
jest.mock('../charts/barchart', () => {
return { BarChart: () => <div className="barchart"></div> };
});
describe('Stat Items Component', () => {
const theme = () => ({ eui: euiDarkVars, darkMode: true });
const state: State = mockGlobalState;

View file

@ -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);

View file

@ -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
}
}
}
}
`;

View file

@ -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<GetEventsOverTimeQuery.Query, GetEventsOverTimeQuery.Variables>
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,
});
}}
</Query>
);
}
}
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);

View file

@ -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",

View file

@ -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;

View file

@ -55,6 +55,9 @@ const HostDetailsBodyComponent = React.memo<HostDetailsBodyComponentProps>(
to: fromTo.to,
});
},
updateDateRange: (min: number, max: number) => {
setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max });
},
})}
</>
) : null

View file

@ -136,7 +136,6 @@ const HostDetailsComponent = React.memo<HostDetailsComponentProps>(
)}
</KpiHostDetailsQuery>
<EuiHorizontalRule />
<SiemNavigation
navTabs={navTabsHostDetails(detailName, hasMlUserPermissions(capabilities))}
display="default"

View file

@ -57,6 +57,9 @@ const HostsBodyComponent = memo<HostsBodyComponentProps>(
to: fromTo.to,
});
},
updateDateRange: (min: number, max: number) => {
setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max });
},
})}
</>
) : null

View file

@ -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<KeyHostsNavTab, NavTab>;
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<KeyHostDetailsNavTab, NavTab> => {
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 (
<HostsQuery
endDate={endDate}
filterQuery={filterQuery}
skip={skip}
sourceId="default"
startDate={startDate}
type={type}
>
{({ hosts, totalCount, loading, pageInfo, loadPage, id, inspect, isInspected, refetch }) => (
<HostsTableManage
deleteQuery={deleteQuery}
data={hosts}
fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)}
id={id}
indexPattern={indexPattern}
inspect={inspect}
isInspect={isInspected}
loading={loading}
loadPage={loadPage}
refetch={refetch}
setQuery={setQuery}
showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)}
totalCount={totalCount}
type={type}
/>
)}
</HostsQuery>
);
};
export const AuthenticationsQueryTabBody = ({
deleteQuery,
endDate,
filterQuery,
skip,
setQuery,
startDate,
type,
}: HostsComponentsQueryProps) => {
return (
<AuthenticationsQuery
endDate={endDate}
filterQuery={filterQuery}
skip={skip}
sourceId="default"
startDate={startDate}
type={type}
>
{({
authentications,
totalCount,
loading,
pageInfo,
loadPage,
id,
inspect,
isInspected,
refetch,
}) => {
return (
<AuthenticationTableManage
data={authentications}
deleteQuery={deleteQuery}
fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)}
id={id}
inspect={inspect}
isInspect={isInspected}
loading={loading}
loadPage={loadPage}
refetch={refetch}
showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)}
setQuery={setQuery}
totalCount={totalCount}
type={type}
/>
);
}}
</AuthenticationsQuery>
);
};
export const UncommonProcessTabBody = ({
deleteQuery,
endDate,
filterQuery,
skip,
setQuery,
startDate,
type,
}: HostsComponentsQueryProps) => {
return (
<UncommonProcessesQuery
endDate={endDate}
filterQuery={filterQuery}
skip={skip}
sourceId="default"
startDate={startDate}
type={type}
>
{({
uncommonProcesses,
totalCount,
loading,
pageInfo,
loadPage,
id,
inspect,
isInspected,
refetch,
}) => (
<UncommonProcessTableManage
deleteQuery={deleteQuery}
data={uncommonProcesses}
fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)}
id={id}
inspect={inspect}
isInspect={isInspected}
loading={loading}
loadPage={loadPage}
refetch={refetch}
setQuery={setQuery}
showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)}
totalCount={totalCount}
type={type}
/>
)}
</UncommonProcessesQuery>
);
};
export const AnomaliesTabBody = ({
endDate,
skip,
startDate,
type,
narrowDateRange,
hostName,
}: AnomaliesQueryTabBodyProps) => {
return (
<AnomaliesHostTable
startDate={startDate}
endDate={endDate}
skip={skip}
type={type}
hostName={hostName}
narrowDateRange={narrowDateRange}
/>
);
};
const EventsOverTimeManage = manageQuery(EventsOverTimeHistogram);
export const EventsTabBody = ({
endDate,
kqlQueryExpression,
startDate,
setQuery,
filterQuery,
updateDateRange = () => {},
}: HostsComponentsQueryProps) => {
const HOSTS_PAGE_TIMELINE_ID = 'hosts-page';
return (
<>
<EventsOverTimeQuery
endDate={endDate}
filterQuery={filterQuery}
sourceId="default"
startDate={startDate}
type={hostsModel.HostsType.page}
>
{({ eventsOverTime, loading, id, inspect, refetch, totalCount }) => (
<EventsOverTimeManage
data={eventsOverTime!}
endDate={endDate}
id={id}
inspect={inspect}
loading={loading}
updateDateRange={updateDateRange}
refetch={refetch}
setQuery={setQuery}
startDate={startDate}
totalCount={totalCount}
/>
)}
</EventsOverTimeQuery>
<EuiSpacer size="l" />
<StatefulEventsViewer
end={endDate}
id={HOSTS_PAGE_TIMELINE_ID}
kqlQueryExpression={kqlQueryExpression}
start={startDate}
/>
</>
);
};

View file

@ -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) => (
<StatefulEventsViewer
end={endDate}
id={HOSTS_PAGE_TIMELINE_ID}
kqlQueryExpression={kqlQueryExpression}
start={startDate}
/>
);
setQuery,
filterQuery,
updateDateRange = () => {},
}: HostsComponentsQueryProps) => {
return (
<>
<EventsOverTimeQuery
endDate={endDate}
filterQuery={filterQuery}
sourceId="default"
startDate={startDate}
type={hostsModel.HostsType.page}
>
{({ eventsOverTime, loading, id, inspect, refetch, totalCount }) => (
<EventsOverTimeManage
data={eventsOverTime!}
endDate={endDate}
id={id}
inspect={inspect}
loading={loading}
refetch={refetch}
setQuery={setQuery}
startDate={startDate}
totalCount={totalCount}
updateDateRange={updateDateRange}
/>
)}
</EventsOverTimeQuery>
<EuiSpacer size="l" />
<StatefulEventsViewer
end={endDate}
id={HOSTS_PAGE_TIMELINE_ID}
kqlQueryExpression={kqlQueryExpression}
start={startDate}
/>
</>
);
};
EventsQueryTabBody.displayName = 'EventsQueryTabBody';

View file

@ -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;
};

View file

@ -32,6 +32,11 @@ export interface EventsResolversDeps {
events: Events;
}
type QueryEventsOverTimeResolver = ChildResolverOf<
AppResolverOf<SourceResolvers.EventsOverTimeResolver>,
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);
},
},
});

View file

@ -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!
}
`;

View file

@ -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<TimelineDetailsData, TypeParent, Context>;
LastEventTime?: LastEventTimeResolver<LastEventTimeData, TypeParent, Context>;
EventsOverTime?: EventsOverTimeResolver<EventsOverTimeData, TypeParent, Context>;
/** Gets Hosts based on timerange and specified criteria, or all events in the timerange if no criteria is specified */
Hosts?: HostsResolver<HostsData, TypeParent, Context>;
@ -2609,6 +2636,19 @@ export namespace SourceResolvers {
defaultIndex: string[];
}
export type EventsOverTimeResolver<
R = EventsOverTimeData,
Parent = Source,
Context = SiemContext
> = Resolver<R, Parent, Context, EventsOverTimeArgs>;
export interface EventsOverTimeArgs {
timerange: TimerangeInput;
filterQuery?: string | null;
defaultIndex: string[];
}
export type HostsResolver<R = HostsData, Parent = Source, Context = SiemContext> = Resolver<
R,
Parent,
@ -5204,6 +5244,58 @@ export namespace LastEventTimeDataResolvers {
> = Resolver<R, Parent, Context>;
}
export namespace EventsOverTimeDataResolvers {
export interface Resolvers<Context = SiemContext, TypeParent = EventsOverTimeData> {
inspect?: InspectResolver<Inspect | null, TypeParent, Context>;
eventsOverTime?: EventsOverTimeResolver<MatrixOverTimeHistogramData[], TypeParent, Context>;
totalCount?: TotalCountResolver<number, TypeParent, Context>;
}
export type InspectResolver<
R = Inspect | null,
Parent = EventsOverTimeData,
Context = SiemContext
> = Resolver<R, Parent, Context>;
export type EventsOverTimeResolver<
R = MatrixOverTimeHistogramData[],
Parent = EventsOverTimeData,
Context = SiemContext
> = Resolver<R, Parent, Context>;
export type TotalCountResolver<
R = number,
Parent = EventsOverTimeData,
Context = SiemContext
> = Resolver<R, Parent, Context>;
}
export namespace MatrixOverTimeHistogramDataResolvers {
export interface Resolvers<Context = SiemContext, TypeParent = MatrixOverTimeHistogramData> {
x?: XResolver<number, TypeParent, Context>;
y?: YResolver<number, TypeParent, Context>;
g?: GResolver<string, TypeParent, Context>;
}
export type XResolver<
R = number,
Parent = MatrixOverTimeHistogramData,
Context = SiemContext
> = Resolver<R, Parent, Context>;
export type YResolver<
R = number,
Parent = MatrixOverTimeHistogramData,
Context = SiemContext
> = Resolver<R, Parent, Context>;
export type GResolver<
R = string,
Parent = MatrixOverTimeHistogramData,
Context = SiemContext
> = Resolver<R, Parent, Context>;
}
export namespace HostsDataResolvers {
export interface Resolvers<Context = SiemContext, TypeParent = HostsData> {
edges?: EdgesResolver<HostsEdges[], TypeParent, Context>;

View file

@ -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<EventsOverTimeData> {
const dsl = buildEventsOverTimeQuery(options);
const response = await this.framework.callWithRequest<EventHit, TermAggregation>(
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<MatrixOverTimeHistogramData>(({ 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,

View file

@ -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<LastEventTimeData> {
return this.adapter.getLastEventTimeData(req, options);
}
public async getEventsOverTime(
req: FrameworkRequest,
options: RequestBasicOptions
): Promise<EventsOverTimeData> {
return this.adapter.getEventsOverTime(req, options);
}
}

View file

@ -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;
};

View file

@ -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<LastEventTimeData>;
getEventsOverTime(
req: FrameworkRequest,
options: RequestBasicOptions
): Promise<EventsOverTimeData>;
}
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;
}

View file

@ -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<Array<number | moment.Duration>>,
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;
};

View file

@ -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,

View file

@ -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<GetEventsOverTimeQuery.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<GetEventsOverTimeQuery.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);
});
});
});
});
}

View file

@ -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'));