diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index 22f1b3beffa35..e0acc7ecfe036 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -111,3 +111,20 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ ]; export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions'; export const NOTIFICATION_THROTTLE_RULE = 'rule'; + +/** + * Histograms for fields named in this list should be displayed with an + * "All others" bucket, to count events that don't specify a value for + * the field being counted + */ +export const showAllOthersBucket: string[] = [ + 'destination.ip', + 'event.action', + 'event.category', + 'event.dataset', + 'event.module', + 'signal.rule.threat.tactic.name', + 'source.ip', + 'destination.ip', + 'user.name', +]; 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 272c41833f368..635d48cca10fc 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 @@ -4,15 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow, ShallowWrapper } from 'enzyme'; +import { Chart, BarSeries, Axis, ScaleType } from '@elastic/charts'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { mount, ReactWrapper, shallow, ShallowWrapper } from 'enzyme'; import React from 'react'; +import { ThemeProvider } from 'styled-components'; + +import { escapeDataProviderId } from '../drag_and_drop/helpers'; +import { TestProviders } from '../../mock'; import { BarChartBaseComponent, BarChartComponent } from './barchart'; import { ChartSeriesData } from './common'; -import { Chart, BarSeries, Axis, ScaleType } from '@elastic/charts'; jest.mock('../../lib/kibana'); +jest.mock('uuid', () => { + return { + v1: jest.fn(() => 'uuid.v1()'), + v4: jest.fn(() => 'uuid.v4()'), + }; +}); + +const theme = () => ({ eui: euiDarkVars, darkMode: true }); + const customHeight = '100px'; const customWidth = '120px'; const chartDataSets = [ @@ -116,6 +130,19 @@ const mockConfig = { customHeight: 324, }; +// Suppress warnings about "react-beautiful-dnd" +/* eslint-disable no-console */ +const originalError = console.error; +const originalWarn = console.warn; +beforeAll(() => { + console.warn = jest.fn(); + console.error = jest.fn(); +}); +afterAll(() => { + console.error = originalError; + console.warn = originalWarn; +}); + describe('BarChartBaseComponent', () => { let shallowWrapper: ShallowWrapper; const mockBarChartData: ChartSeriesData[] = [ @@ -280,6 +307,91 @@ describe.each(chartDataSets)('BarChart with valid data [%o]', data => { expect(shallowWrapper.find('BarChartBase')).toHaveLength(1); expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(0); }); + + it('it does NOT render a draggable legend because stackByField is not provided', () => { + expect(shallowWrapper.find('[data-test-subj="draggable-legend"]').exists()).toBe(false); + }); +}); + +describe.each(chartDataSets)('BarChart with stackByField', () => { + let wrapper: ReactWrapper; + + const data = [ + { + key: 'python.exe', + value: [ + { + x: 1586754900000, + y: 9675, + g: 'python.exe', + }, + ], + }, + { + key: 'kernel', + value: [ + { + x: 1586754900000, + y: 8708, + g: 'kernel', + }, + { + x: 1586757600000, + y: 9282, + g: 'kernel', + }, + ], + }, + { + key: 'sshd', + value: [ + { + x: 1586754900000, + y: 5907, + g: 'sshd', + }, + ], + }, + ]; + + const expectedColors = ['#1EA593', '#2B70F7', '#CE0060']; + + const stackByField = 'process.name'; + + beforeAll(() => { + wrapper = mount( + + + + + + ); + }); + + it('it renders a draggable legend', () => { + expect(wrapper.find('[data-test-subj="draggable-legend"]').exists()).toBe(true); + }); + + expectedColors.forEach((color, i) => { + test(`it renders the expected legend color ${color} for legend item ${i}`, () => { + expect(wrapper.find(`div [color="${color}"]`).exists()).toBe(true); + }); + }); + + data.forEach(datum => { + test(`it renders the expected draggable legend text for datum ${datum.key}`, () => { + const dataProviderId = `draggableId.content.draggable-legend-item-uuid_v4()-${escapeDataProviderId( + stackByField + )}-${escapeDataProviderId(datum.key)}`; + + expect( + wrapper + .find(`div [data-rbd-draggable-id="${dataProviderId}"]`) + .first() + .text() + ).toEqual(datum.key); + }); + }); }); describe.each(chartHolderDataSets)('BarChart with invalid data [%o]', data => { 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 2ae0e05850a37..64d15cd6731cb 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx @@ -4,13 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useMemo } from 'react'; import { Chart, BarSeries, Axis, Position, ScaleType, Settings } from '@elastic/charts'; import { getOr, get, isNumber } from 'lodash/fp'; import deepmerge from 'deepmerge'; +import uuid from 'uuid'; +import styled from 'styled-components'; -import { useThrottledResizeObserver } from '../utils'; +import { escapeDataProviderId } from '../drag_and_drop/helpers'; import { useTimeZone } from '../../lib/kibana'; +import { defaultLegendColors } from '../matrix_histogram/utils'; +import { useThrottledResizeObserver } from '../utils'; + import { ChartPlaceHolder } from './chart_place_holder'; import { chartDefaultSettings, @@ -22,6 +28,12 @@ import { WrappedByAutoSizer, useTheme, } from './common'; +import { DraggableLegend } from './draggable_legend'; +import { LegendItem } from './draggable_legend_item'; + +const LegendFlexItem = styled(EuiFlexItem)` + overview: hidden; +`; const checkIfAllTheDataInTheSeriesAreValid = (series: ChartSeriesData): series is ChartSeriesData => series != null && @@ -38,12 +50,14 @@ const checkIfAnyValidSeriesExist = ( // Bar chart rotation: https://ela.st/chart-rotations export const BarChartBaseComponent = ({ data, + forceHiddenLegend = false, ...chartConfigs }: { data: ChartSeriesData[]; width: string | null | undefined; height: string | null | undefined; configs?: ChartSeriesConfigs | undefined; + forceHiddenLegend?: boolean; }) => { const theme = useTheme(); const timeZone = useTimeZone(); @@ -59,10 +73,10 @@ export const BarChartBaseComponent = ({ return chartConfigs.width && chartConfigs.height ? ( - + {data.map(series => { const barSeriesKey = series.key; - return checkIfAllTheDataInTheSeriesAreValid ? ( + return checkIfAllTheDataInTheSeriesAreValid(series) ? ( = ({ barChart, configs }) => { +const NO_LEGEND_DATA: LegendItem[] = []; + +export const BarChartComponent: React.FC = ({ + barChart, + configs, + stackByField, +}) => { const { ref: measureRef, width, height } = useThrottledResizeObserver(); + const legendItems: LegendItem[] = useMemo( + () => + barChart != null && stackByField != null + ? barChart.map((d, i) => ({ + color: d.color ?? i < defaultLegendColors.length ? defaultLegendColors[i] : undefined, + dataProviderId: escapeDataProviderId( + `draggable-legend-item-${uuid.v4()}-${stackByField}-${d.key}` + ), + field: stackByField, + value: d.key, + })) + : NO_LEGEND_DATA, + [barChart, stackByField] + ); + const customHeight = get('customHeight', configs); const customWidth = get('customWidth', configs); const chartHeight = getChartHeight(customHeight, height); const chartWidth = getChartWidth(customWidth, width); return checkIfAnyValidSeriesExist(barChart) ? ( - - - + + + + + + + + + + ) : ( ); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend.test.tsx new file mode 100644 index 0000000000000..0da0c2bdc35f2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend.test.tsx @@ -0,0 +1,149 @@ +/* + * 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 euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { mount, ReactWrapper } from 'enzyme'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; + +import { TestProviders } from '../../mock'; + +import { MIN_LEGEND_HEIGHT, DraggableLegend } from './draggable_legend'; +import { LegendItem } from './draggable_legend_item'; + +const theme = () => ({ eui: euiDarkVars, darkMode: true }); + +const allOthersDataProviderId = + 'draggable-legend-item-527adabe-8e1c-4a1f-965c-2f3d65dda9e1-event_dataset-All others'; + +const legendItems: LegendItem[] = [ + { + color: '#1EA593', + dataProviderId: 'draggable-legend-item-3207fda7-d008-402a-86a0-8ad632081bad-event_dataset-flow', + field: 'event.dataset', + value: 'flow', + }, + { + color: '#2B70F7', + dataProviderId: + 'draggable-legend-item-83f6c824-811d-4ec8-b373-eba2b0de6398-event_dataset-suricata_eve', + field: 'event.dataset', + value: 'suricata.eve', + }, + { + color: '#CE0060', + dataProviderId: + 'draggable-legend-item-ec57bb8f-82cd-4e07-bd38-1d11b3f0ee5f-event_dataset-traefik_access', + field: 'event.dataset', + value: 'traefik.access', + }, + { + color: '#38007E', + dataProviderId: + 'draggable-legend-item-25d5fcd6-87ba-46b5-893e-c655d7d504e3-event_dataset-esensor', + field: 'event.dataset', + value: 'esensor', + }, + { + color: '#F37020', + dataProviderId: allOthersDataProviderId, + field: 'event.dataset', + value: 'All others', + }, +]; + +describe('DraggableLegend', () => { + const height = 400; + + // Suppress warnings about "react-beautiful-dnd" + /* eslint-disable no-console */ + const originalError = console.error; + const originalWarn = console.warn; + beforeAll(() => { + console.warn = jest.fn(); + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + console.warn = originalWarn; + }); + + describe('rendering', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + wrapper = mount( + + + + + + ); + }); + + it(`renders a container with the specified non-zero 'height'`, () => { + expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( + 'height', + `${height}px` + ); + }); + + it('scrolls when necessary', () => { + expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( + 'overflow', + 'auto' + ); + }); + + it('renders the legend items', () => { + legendItems.forEach(item => + expect( + wrapper + .find( + item.dataProviderId !== allOthersDataProviderId + ? `[data-test-subj="legend-item-${item.dataProviderId}"]` + : '[data-test-subj="all-others-legend-item"]' + ) + .first() + .text() + ).toEqual(item.value) + ); + }); + + it('renders a spacer for every legend item', () => { + expect(wrapper.find('[data-test-subj="draggable-legend-spacer"]').hostNodes().length).toEqual( + legendItems.length + ); + }); + }); + + it('does NOT render the legend when an empty collection of legendItems is provided', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="draggable-legend"]').exists()).toBe(false); + }); + + it(`renders a legend with the minimum height when 'height' is zero`, () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( + 'height', + `${MIN_LEGEND_HEIGHT}px` + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend.tsx b/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend.tsx new file mode 100644 index 0000000000000..ef3fbb8780d15 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import { rgba } from 'polished'; +import React from 'react'; +import styled from 'styled-components'; + +import { DraggableLegendItem, LegendItem } from './draggable_legend_item'; + +export const MIN_LEGEND_HEIGHT = 175; + +const DraggableLegendContainer = styled.div<{ height: number }>` + height: ${({ height }) => `${height}px`}; + overflow: auto; + scrollbar-width: thin; + width: 165px; + + &::-webkit-scrollbar { + height: ${({ theme }) => theme.eui.euiScrollBar}; + width: ${({ theme }) => theme.eui.euiScrollBar}; + } + + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)}; + border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent; + } + + &::-webkit-scrollbar-corner, + &::-webkit-scrollbar-track { + background-color: transparent; + } +`; + +const DraggableLegendComponent: React.FC<{ + height: number; + legendItems: LegendItem[]; +}> = ({ height, legendItems }) => { + if (legendItems.length === 0) { + return null; + } + + return ( + + + + {legendItems.map(item => ( + + + + + ))} + + + + ); +}; + +DraggableLegendComponent.displayName = 'DraggableLegendComponent'; + +export const DraggableLegend = React.memo(DraggableLegendComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend_item.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend_item.test.tsx new file mode 100644 index 0000000000000..581952a8415f6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend_item.test.tsx @@ -0,0 +1,143 @@ +/* + * 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 euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { mount, ReactWrapper } from 'enzyme'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; + +import { TestProviders } from '../../mock'; + +import { DraggableLegendItem, LegendItem } from './draggable_legend_item'; + +const theme = () => ({ eui: euiDarkVars, darkMode: true }); + +describe('DraggableLegendItem', () => { + // Suppress warnings about "react-beautiful-dnd" + /* eslint-disable no-console */ + const originalError = console.error; + const originalWarn = console.warn; + beforeAll(() => { + console.warn = jest.fn(); + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + console.warn = originalWarn; + }); + + describe('rendering a regular (non "All others") legend item', () => { + const legendItem: LegendItem = { + color: '#1EA593', + dataProviderId: + 'draggable-legend-item-3207fda7-d008-402a-86a0-8ad632081bad-event_dataset-flow', + field: 'event.dataset', + value: 'flow', + }; + + let wrapper: ReactWrapper; + + beforeEach(() => { + wrapper = mount( + + + + + + ); + }); + + it('renders a colored circle with the expected legend item color', () => { + expect( + wrapper + .find('[data-test-subj="legend-color"]') + .first() + .props().color + ).toEqual(legendItem.color); + }); + + it('renders draggable legend item text', () => { + expect( + wrapper + .find(`[data-test-subj="legend-item-${legendItem.dataProviderId}"]`) + .first() + .text() + ).toEqual(legendItem.value); + }); + + it('does NOT render a non-draggable "All others" legend item', () => { + expect(wrapper.find(`[data-test-subj="all-others-legend-item"]`).exists()).toBe(false); + }); + }); + + describe('rendering an "All others" legend item', () => { + const allOthersLegendItem: LegendItem = { + color: '#F37020', + dataProviderId: + 'draggable-legend-item-527adabe-8e1c-4a1f-965c-2f3d65dda9e1-event_dataset-All others', + field: 'event.dataset', + value: 'All others', + }; + + let wrapper: ReactWrapper; + + beforeEach(() => { + wrapper = mount( + + + + + + ); + }); + + it('renders a colored circle with the expected legend item color', () => { + expect( + wrapper + .find('[data-test-subj="legend-color"]') + .first() + .props().color + ).toEqual(allOthersLegendItem.color); + }); + + it('does NOT render a draggable legend item', () => { + expect( + wrapper + .find(`[data-test-subj="legend-item-${allOthersLegendItem.dataProviderId}"]`) + .exists() + ).toBe(false); + }); + + it('renders NON-draggable `All others` legend item text', () => { + expect( + wrapper + .find(`[data-test-subj="all-others-legend-item"]`) + .first() + .text() + ).toEqual(allOthersLegendItem.value); + }); + }); + + it('does NOT render a colored circle when the legend item has no color', () => { + const noColorLegendItem: LegendItem = { + // no `color` attribute for this `LegendItem`! + dataProviderId: + 'draggable-legend-item-3207fda7-d008-402a-86a0-8ad632081bad-event_dataset-flow', + field: 'event.dataset', + value: 'flow', + }; + + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="legend-color"]').exists()).toBe(false); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend_item.tsx b/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend_item.tsx new file mode 100644 index 0000000000000..cdda1733932d5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/charts/draggable_legend_item.tsx @@ -0,0 +1,62 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import { DefaultDraggable } from '../draggables'; + +import * as i18n from './translation'; + +// The "All others" legend item is not draggable +const AllOthers = styled.span` + padding-left: 7px; +`; + +export interface LegendItem { + color?: string; + dataProviderId: string; + field: string; + value: string; +} + +const DraggableLegendItemComponent: React.FC<{ + legendItem: LegendItem; +}> = ({ legendItem }) => { + const { color, dataProviderId, field, value } = legendItem; + + return ( + + + {color != null && ( + + + + )} + + + {value !== i18n.ALL_OTHERS ? ( + + ) : ( + <> + {value} + + )} + + + + ); +}; + +DraggableLegendItemComponent.displayName = 'DraggableLegendItemComponent'; + +export const DraggableLegendItem = React.memo(DraggableLegendItemComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/translation.ts b/x-pack/legacy/plugins/siem/public/components/charts/translation.ts index 341cb7782f87c..891f59fc97bd1 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/translation.ts +++ b/x-pack/legacy/plugins/siem/public/components/charts/translation.ts @@ -13,3 +13,7 @@ export const ALL_VALUES_ZEROS_TITLE = i18n.translate('xpack.siem.chart.dataAllVa export const DATA_NOT_AVAILABLE_TITLE = i18n.translate('xpack.siem.chart.dataNotAvailableTitle', { defaultMessage: 'Chart Data Not Available', }); + +export const ALL_OTHERS = i18n.translate('xpack.siem.chart.allOthersGroupingLabel', { + defaultMessage: 'All others', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx index 11db33fff6d72..248ae671550ef 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -27,6 +27,9 @@ import { draggableIsField, } from './helpers'; +// @ts-ignore +window['__react-beautiful-dnd-disable-dev-warnings'] = true; + interface Props { browserFields: BrowserFields; children: React.ReactNode; diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.test.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.test.tsx index 11891afabbf3d..cd9e1dc95ff01 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.test.tsx @@ -7,12 +7,13 @@ import { shallow } from 'enzyme'; import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; +import { DraggableStateSnapshot, DraggingStyle } from 'react-beautiful-dnd'; import { mockBrowserFields, mocksSource } from '../../containers/source/mock'; import { TestProviders } from '../../mock'; import { mockDataProviders } from '../timeline/data_providers/mock/mock_data_providers'; import { DragDropContextWrapper } from './drag_drop_context_wrapper'; -import { DraggableWrapper, ConditionalPortal } from './draggable_wrapper'; +import { ConditionalPortal, DraggableWrapper, getStyle } from './draggable_wrapper'; import { useMountAppended } from '../../utils/use_mount_appended'; describe('DraggableWrapper', () => { @@ -48,6 +49,36 @@ describe('DraggableWrapper', () => { expect(wrapper.text()).toEqual(message); }); + + test('it does NOT render hover actions when the mouse is NOT over the draggable wrapper', () => { + const wrapper = mount( + + + + message} /> + + + + ); + + expect(wrapper.find('[data-test-subj="copy-to-clipboard"]').exists()).toBe(false); + }); + + test('it renders hover actions when the mouse is over the draggable wrapper', () => { + const wrapper = mount( + + + + message} /> + + + + ); + + wrapper.simulate('mouseenter'); + wrapper.update(); + expect(wrapper.find('[data-test-subj="copy-to-clipboard"]').exists()).toBe(true); + }); }); describe('text truncation styling', () => { @@ -100,4 +131,36 @@ describe('ConditionalPortal', () => { expect(props.registerProvider.mock.calls.length).toEqual(1); }); + + describe('getStyle', () => { + const style: DraggingStyle = { + boxSizing: 'border-box', + height: 10, + left: 1, + pointerEvents: 'none', + position: 'fixed', + transition: 'none', + top: 123, + width: 50, + zIndex: 9999, + }; + + it('returns a style with no transitionDuration when the snapshot is not drop animating', () => { + const snapshot: DraggableStateSnapshot = { + isDragging: true, + isDropAnimating: false, // <-- NOT drop animating + }; + + expect(getStyle(style, snapshot)).not.toHaveProperty('transitionDuration'); + }); + + it('returns a style with a transitionDuration when the snapshot is drop animating', () => { + const snapshot: DraggableStateSnapshot = { + isDragging: true, + isDropAnimating: true, // <-- it is drop animating + }; + + expect(getStyle(style, snapshot)).toHaveProperty('transitionDuration', '0.00000001s'); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx index 3a6a4de7984db..c7da5b5c58951 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Draggable, DraggableProvided, DraggableStateSnapshot, + DraggingStyle, Droppable, + NotDraggingStyle, } from 'react-beautiful-dnd'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; @@ -18,6 +20,9 @@ import deepEqual from 'fast-deep-equal'; import { dragAndDropActions } from '../../store/drag_and_drop'; import { DataProvider } from '../timeline/data_providers/data_provider'; import { TruncatableText } from '../truncatable_text'; +import { WithHoverActions } from '../with_hover_actions'; + +import { DraggableWrapperHoverContent } from './draggable_wrapper_hover_content'; import { getDraggableId, getDroppableId } from './helpers'; import { ProviderContainer } from './provider_container'; @@ -67,23 +72,42 @@ type RenderFunctionProp = ( state: DraggableStateSnapshot ) => React.ReactNode; -interface OwnProps { +interface Props { dataProvider: DataProvider; inline?: boolean; render: RenderFunctionProp; truncate?: boolean; + onFilterAdded?: () => void; } -type Props = OwnProps; - /** * Wraps a draggable component to handle registration / unregistration of the * data provider associated with the item being dropped */ +export const getStyle = ( + style: DraggingStyle | NotDraggingStyle | undefined, + snapshot: DraggableStateSnapshot +) => { + if (!snapshot.isDropAnimating) { + return style; + } + + return { + ...style, + transitionDuration: '0.00000001s', // cannot be 0, but can be a very short duration + }; +}; + export const DraggableWrapper = React.memo( - ({ dataProvider, render, truncate }) => { + ({ dataProvider, onFilterAdded, render, truncate }) => { + const [showTopN, setShowTopN] = useState(false); + const toggleTopN = useCallback(() => { + setShowTopN(!showTopN); + }, [setShowTopN, showTopN]); + const [providerRegistered, setProviderRegistered] = useState(false); + const dispatch = useDispatch(); const registerProvider = useCallback(() => { @@ -105,65 +129,90 @@ export const DraggableWrapper = React.memo( [] ); - return ( - - - ( - -
- ( + + ), + [dataProvider, onFilterAdded, showTopN, toggleTopN] + ); + + const renderContent = useCallback( + () => ( + + + ( + +
- {render(dataProvider, provided, snapshot)} - -
-
- )} - > - {droppableProvided => ( -
- - {(provided, snapshot) => ( - - {truncate && !snapshot.isDragging ? ( - - {render(dataProvider, provided, snapshot)} - - ) : ( - - {render(dataProvider, provided, snapshot)} - - )} - - )} - - {droppableProvided.placeholder} -
- )} -
-
-
+ {render(dataProvider, provided, snapshot)} +
+
+
+ )} + > + {droppableProvided => ( +
+ + {(provided, snapshot) => ( + + {truncate && !snapshot.isDragging ? ( + + {render(dataProvider, provided, snapshot)} + + ) : ( + + {render(dataProvider, provided, snapshot)} + + )} + + )} + + {droppableProvided.placeholder} +
+ )} +
+
+
+ ), + [dataProvider, render, registerProvider, truncate] + ); + + return ( + ); }, (prevProps, nextProps) => diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx new file mode 100644 index 0000000000000..f8b5eb7209ff4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx @@ -0,0 +1,559 @@ +/* + * 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 { mount, ReactWrapper } from 'enzyme'; +import React from 'react'; +import { MockedProvider } from 'react-apollo/test-utils'; + +import { mocksSource } from '../../containers/source/mock'; +import { wait } from '../../lib/helpers'; +import { useKibana } from '../../lib/kibana'; +import { TestProviders } from '../../mock'; +import { createKibanaCoreStartMock } from '../../mock/kibana_core'; +import { FilterManager } from '../../../../../../../src/plugins/data/public'; +import { TimelineContext } from '../timeline/timeline_context'; + +import { DraggableWrapperHoverContent } from './draggable_wrapper_hover_content'; + +jest.mock('../../lib/kibana'); + +jest.mock('uuid', () => { + return { + v1: jest.fn(() => 'uuid.v1()'), + v4: jest.fn(() => 'uuid.v4()'), + }; +}); + +const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; +const field = 'process.name'; +const value = 'nice'; + +describe('DraggableWrapperHoverContent', () => { + // Suppress warnings about "react-beautiful-dnd" + /* eslint-disable no-console */ + const originalError = console.error; + const originalWarn = console.warn; + beforeAll(() => { + console.warn = jest.fn(); + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + console.warn = originalWarn; + }); + + /** + * The tests for "Filter for value" and "Filter out value" are similar enough + * to combine them into "table tests" using this array + */ + const forOrOut = ['for', 'out']; + + forOrOut.forEach(hoverAction => { + describe(`Filter ${hoverAction} value`, () => { + test(`it renders the 'Filter ${hoverAction} value' button when showTopN is false`, () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="filter-${hoverAction}-value"]`) + .first() + .exists() + ).toBe(true); + }); + + test(`it does NOT render the 'Filter ${hoverAction} value' button when showTopN is true`, () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="filter-${hoverAction}-value"]`) + .first() + .exists() + ).toBe(false); + }); + + describe('when run in the context of a timeline', () => { + let filterManager: FilterManager; + let wrapper: ReactWrapper; + let onFilterAdded: () => void; + + beforeEach(() => { + filterManager = new FilterManager(mockUiSettingsForFilterManager); + filterManager.addFilters = jest.fn(); + onFilterAdded = jest.fn(); + + wrapper = mount( + + + + + + ); + }); + + test('when clicked, it adds a filter to the timeline when running in the context of a timeline', () => { + wrapper + .find(`[data-test-subj="filter-${hoverAction}-value"]`) + .first() + .simulate('click'); + wrapper.update(); + + expect(filterManager.addFilters).toBeCalledWith({ + meta: { + alias: null, + disabled: false, + key: 'process.name', + negate: hoverAction === 'out' ? true : false, + params: { query: 'nice' }, + type: 'phrase', + value: 'nice', + }, + query: { match: { 'process.name': { query: 'nice', type: 'phrase' } } }, + }); + }); + + test('when clicked, invokes onFilterAdded when running in the context of a timeline', () => { + wrapper + .find(`[data-test-subj="filter-${hoverAction}-value"]`) + .first() + .simulate('click'); + wrapper.update(); + + expect(onFilterAdded).toBeCalled(); + }); + }); + + describe('when NOT run in the context of a timeline', () => { + let wrapper: ReactWrapper; + let onFilterAdded: () => void; + const kibana = useKibana(); + + beforeEach(() => { + kibana.services.data.query.filterManager.addFilters = jest.fn(); + onFilterAdded = jest.fn(); + + wrapper = mount( + + + + ); + }); + + test('when clicked, it adds a filter to the global filters when NOT running in the context of a timeline', () => { + wrapper + .find(`[data-test-subj="filter-${hoverAction}-value"]`) + .first() + .simulate('click'); + wrapper.update(); + + expect(kibana.services.data.query.filterManager.addFilters).toBeCalledWith({ + meta: { + alias: null, + disabled: false, + key: 'process.name', + negate: hoverAction === 'out' ? true : false, + params: { query: 'nice' }, + type: 'phrase', + value: 'nice', + }, + query: { match: { 'process.name': { query: 'nice', type: 'phrase' } } }, + }); + }); + + test('when clicked, invokes onFilterAdded when NOT running in the context of a timeline', () => { + wrapper + .find(`[data-test-subj="filter-${hoverAction}-value"]`) + .first() + .simulate('click'); + wrapper.update(); + + expect(onFilterAdded).toBeCalled(); + }); + }); + + describe('an empty string value when run in the context of a timeline', () => { + let filterManager: FilterManager; + let wrapper: ReactWrapper; + let onFilterAdded: () => void; + + beforeEach(() => { + filterManager = new FilterManager(mockUiSettingsForFilterManager); + filterManager.addFilters = jest.fn(); + onFilterAdded = jest.fn(); + + wrapper = mount( + + + + + + ); + }); + + const expectedFilterTypeDescription = + hoverAction === 'for' ? 'a "NOT exists"' : 'an "exists"'; + test(`when clicked, it adds ${expectedFilterTypeDescription} filter to the timeline when run in the context of a timeline`, () => { + const expected = + hoverAction === 'for' + ? { + exists: { field: 'process.name' }, + meta: { + alias: null, + disabled: false, + key: 'process.name', + negate: true, + type: 'exists', + value: 'exists', + }, + } + : { + exists: { field: 'process.name' }, + meta: { + alias: null, + disabled: false, + key: 'process.name', + negate: false, + type: 'exists', + value: 'exists', + }, + }; + + wrapper + .find(`[data-test-subj="filter-${hoverAction}-value"]`) + .first() + .simulate('click'); + wrapper.update(); + + expect(filterManager.addFilters).toBeCalledWith(expected); + }); + }); + + describe('an empty string value when NOT run in the context of a timeline', () => { + let wrapper: ReactWrapper; + let onFilterAdded: () => void; + const kibana = useKibana(); + + beforeEach(() => { + kibana.services.data.query.filterManager.addFilters = jest.fn(); + onFilterAdded = jest.fn(); + + wrapper = mount( + + + + ); + }); + + const expectedFilterTypeDescription = + hoverAction === 'for' ? 'a "NOT exists"' : 'an "exists"'; + test(`when clicked, it adds ${expectedFilterTypeDescription} filter to the global filters when NOT running in the context of a timeline`, () => { + const expected = + hoverAction === 'for' + ? { + exists: { field: 'process.name' }, + meta: { + alias: null, + disabled: false, + key: 'process.name', + negate: true, + type: 'exists', + value: 'exists', + }, + } + : { + exists: { field: 'process.name' }, + meta: { + alias: null, + disabled: false, + key: 'process.name', + negate: false, + type: 'exists', + value: 'exists', + }, + }; + + wrapper + .find(`[data-test-subj="filter-${hoverAction}-value"]`) + .first() + .simulate('click'); + wrapper.update(); + + expect(kibana.services.data.query.filterManager.addFilters).toBeCalledWith(expected); + }); + }); + }); + }); + + describe('Top N', () => { + test(`it renders the 'Show top field' button when showTopN is false and an aggregatable string field is provided`, async () => { + const aggregatableStringField = 'cloud.account.id'; + const wrapper = mount( + + + + + + ); + + await wait(); // https://github.com/apollographql/react-apollo/issues/1711 + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="show-top-field"]') + .first() + .exists() + ).toBe(true); + }); + + test(`it renders the 'Show top field' button when showTopN is false and a whitelisted signal field is provided`, async () => { + const whitelistedField = 'signal.rule.name'; + const wrapper = mount( + + + + + + ); + + await wait(); + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="show-top-field"]') + .first() + .exists() + ).toBe(true); + }); + + test(`it does NOT render the 'Show top field' button when showTopN is false and a field not known to BrowserFields is provided`, async () => { + const notKnownToBrowserFields = 'unknown.field'; + const wrapper = mount( + + + + + + ); + + await wait(); + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="show-top-field"]') + .first() + .exists() + ).toBe(false); + }); + + test(`invokes the toggleTopN function when the 'Show top field' button is clicked`, async () => { + const toggleTopN = jest.fn(); + const whitelistedField = 'signal.rule.name'; + const wrapper = mount( + + + + + + ); + + await wait(); + wrapper.update(); + + wrapper + .find('[data-test-subj="show-top-field"]') + .first() + .simulate('click'); + wrapper.update(); + + expect(toggleTopN).toBeCalled(); + }); + + test(`it does NOT render the Top N histogram when when showTopN is false`, async () => { + const whitelistedField = 'signal.rule.name'; + const wrapper = mount( + + + + + + ); + + await wait(); + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="eventsByDatasetOverviewPanel"]') + .first() + .exists() + ).toBe(false); + }); + + test(`it does NOT render the 'Show top field' button when showTopN is true`, async () => { + const whitelistedField = 'signal.rule.name'; + const wrapper = mount( + + + + + + ); + + await wait(); + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="show-top-field"]') + .first() + .exists() + ).toBe(false); + }); + + test(`it renders the Top N histogram when when showTopN is true`, async () => { + const whitelistedField = 'signal.rule.name'; + const wrapper = mount( + + + + + + ); + + await wait(); + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="eventsByDatasetOverview-uuid.v4()Panel"]') + .first() + .exists() + ).toBe(true); + }); + }); + + describe('Copy to Clipboard', () => { + test(`it renders the 'Copy to Clipboard' button when showTopN is false`, () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="copy-to-clipboard"]`) + .first() + .exists() + ).toBe(true); + }); + + test(`it does NOT render the 'Copy to Clipboard' button when showTopN is true`, () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="copy-to-clipboard"]`) + .first() + .exists() + ).toBe(false); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper_hover_content.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper_hover_content.tsx new file mode 100644 index 0000000000000..40725bea498f1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper_hover_content.tsx @@ -0,0 +1,145 @@ +/* + * 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; + +import { getAllFieldsByName, WithSource } from '../../containers/source'; +import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; +import { useKibana } from '../../lib/kibana'; +import { createFilter } from '../page/add_filter_to_global_search_bar'; +import { useTimelineContext } from '../timeline/timeline_context'; +import { StatefulTopN } from '../top_n'; + +import { allowTopN } from './helpers'; +import * as i18n from './translations'; + +interface Props { + field: string; + onFilterAdded?: () => void; + showTopN: boolean; + toggleTopN: () => void; + value?: string[] | string | null; +} + +const DraggableWrapperHoverContentComponent: React.FC = ({ + field, + onFilterAdded, + showTopN, + toggleTopN, + value, +}) => { + const kibana = useKibana(); + const { filterManager: timelineFilterManager } = useTimelineContext(); + const filterManager = useMemo(() => kibana.services.data.query.filterManager, [ + kibana.services.data.query.filterManager, + ]); + + const filterForValue = useCallback(() => { + const filter = + value?.length === 0 ? createFilter(field, undefined) : createFilter(field, value); + const activeFilterManager = timelineFilterManager ?? filterManager; + + if (activeFilterManager != null) { + activeFilterManager.addFilters(filter); + + if (onFilterAdded != null) { + onFilterAdded(); + } + } + }, [field, value, timelineFilterManager, filterManager, onFilterAdded]); + + const filterOutValue = useCallback(() => { + const filter = + value?.length === 0 ? createFilter(field, null, false) : createFilter(field, value, true); + const activeFilterManager = timelineFilterManager ?? filterManager; + + if (activeFilterManager != null) { + activeFilterManager.addFilters(filter); + + if (onFilterAdded != null) { + onFilterAdded(); + } + } + }, [field, value, timelineFilterManager, filterManager, onFilterAdded]); + + return ( + <> + {!showTopN && value != null && ( + + + + )} + + {!showTopN && value != null && ( + + + + )} + + + {({ browserFields }) => ( + <> + {allowTopN({ + browserField: getAllFieldsByName(browserFields)[field], + fieldName: field, + }) && ( + <> + {!showTopN && ( + + + + )} + + {showTopN && ( + + )} + + )} + + )} + + + {!showTopN && ( + + + + )} + + ); +}; + +DraggableWrapperHoverContentComponent.displayName = 'DraggableWrapperHoverContentComponent'; + +export const DraggableWrapperHoverContent = React.memo(DraggableWrapperHoverContentComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.test.ts b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.test.ts index af4b9b280f3cd..753fa5b54eade 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.test.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { omit } from 'lodash/fp'; + import { + allowTopN, destinationIsTimelineButton, destinationIsTimelineColumns, destinationIsTimelineProviders, @@ -717,4 +720,96 @@ describe('helpers', () => { expect(escaped).toEqual('hello.how.are.you?'); }); }); + + describe('#allowTopN', () => { + const aggregatableAllowedType = { + category: 'cloud', + description: + 'The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier.', + example: '666777888999', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'cloud.account.id', + searchable: true, + type: 'string', + aggregatable: true, + format: '', + }; + + test('it returns true for an aggregatable field that is an allowed type', () => { + expect( + allowTopN({ + browserField: aggregatableAllowedType, + fieldName: aggregatableAllowedType.name, + }) + ).toBe(true); + }); + + test('it returns true for a whitelisted non-BrowserField', () => { + expect( + allowTopN({ + browserField: undefined, + fieldName: 'signal.rule.name', + }) + ).toBe(true); + }); + + test('it returns false for a NON-aggregatable field that is an allowed type', () => { + const nonAggregatableAllowedType = { + ...aggregatableAllowedType, + aggregatable: false, + }; + + expect( + allowTopN({ + browserField: nonAggregatableAllowedType, + fieldName: nonAggregatableAllowedType.name, + }) + ).toBe(false); + }); + + test('it returns false for a aggregatable field that is NOT an allowed type', () => { + const aggregatableNotAllowedType = { + ...aggregatableAllowedType, + type: 'not-an-allowed-type', + }; + + expect( + allowTopN({ + browserField: aggregatableNotAllowedType, + fieldName: aggregatableNotAllowedType.name, + }) + ).toBe(false); + }); + + test('it returns false if the BrowserField is missing the aggregatable property', () => { + const missingAggregatable = omit('aggregatable', aggregatableAllowedType); + + expect( + allowTopN({ + browserField: missingAggregatable, + fieldName: missingAggregatable.name, + }) + ).toBe(false); + }); + + test('it returns false if the BrowserField is missing the type property', () => { + const missingType = omit('type', aggregatableAllowedType); + + expect( + allowTopN({ + browserField: missingType, + fieldName: missingType.name, + }) + ).toBe(false); + }); + + test('it returns false for a non-whitelisted field when a BrowserField is not provided', () => { + expect( + allowTopN({ + browserField: undefined, + fieldName: 'non-whitelisted', + }) + ).toBe(false); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.ts b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.ts index 82ddd2c9f29d7..cd3d7cc68d537 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/helpers.ts @@ -9,7 +9,7 @@ import { DropResult } from 'react-beautiful-dnd'; import { Dispatch } from 'redux'; import { ActionCreator } from 'typescript-fsa'; -import { BrowserFields, getAllFieldsByName } from '../../containers/source'; +import { BrowserField, BrowserFields, getAllFieldsByName } from '../../containers/source'; import { IdToDataProvider } from '../../store/drag_and_drop/model'; import { ColumnHeaderOptions } from '../../store/timeline/model'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants'; @@ -227,3 +227,98 @@ export const IS_DRAGGING_CLASS_NAME = 'is-dragging'; /** This class is added to the document body while timeline field dragging */ export const IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME = 'is-timeline-field-dragging'; + +export const allowTopN = ({ + browserField, + fieldName, +}: { + browserField: Partial | undefined; + fieldName: string; +}): boolean => { + const isAggregatable = browserField?.aggregatable ?? false; + const fieldType = browserField?.type ?? ''; + const isAllowedType = [ + 'boolean', + 'geo-point', + 'geo-shape', + 'ip', + 'keyword', + 'number', + 'numeric', + 'string', + ].includes(fieldType); + + // TODO: remove this explicit whitelist when the ECS documentation includes signals + const isWhitelistedNonBrowserField = [ + 'signal.ancestors.depth', + 'signal.ancestors.id', + 'signal.ancestors.rule', + 'signal.ancestors.type', + 'signal.original_event.action', + 'signal.original_event.category', + 'signal.original_event.code', + 'signal.original_event.created', + 'signal.original_event.dataset', + 'signal.original_event.duration', + 'signal.original_event.end', + 'signal.original_event.hash', + 'signal.original_event.id', + 'signal.original_event.kind', + 'signal.original_event.module', + 'signal.original_event.original', + 'signal.original_event.outcome', + 'signal.original_event.provider', + 'signal.original_event.risk_score', + 'signal.original_event.risk_score_norm', + 'signal.original_event.sequence', + 'signal.original_event.severity', + 'signal.original_event.start', + 'signal.original_event.timezone', + 'signal.original_event.type', + 'signal.original_time', + 'signal.parent.depth', + 'signal.parent.id', + 'signal.parent.index', + 'signal.parent.rule', + 'signal.parent.type', + 'signal.rule.created_by', + 'signal.rule.description', + 'signal.rule.enabled', + 'signal.rule.false_positives', + 'signal.rule.filters', + 'signal.rule.from', + 'signal.rule.id', + 'signal.rule.immutable', + 'signal.rule.index', + 'signal.rule.interval', + 'signal.rule.language', + 'signal.rule.max_signals', + 'signal.rule.name', + 'signal.rule.note', + 'signal.rule.output_index', + 'signal.rule.query', + 'signal.rule.references', + 'signal.rule.risk_score', + 'signal.rule.rule_id', + 'signal.rule.saved_id', + 'signal.rule.severity', + 'signal.rule.size', + 'signal.rule.tags', + 'signal.rule.threat', + 'signal.rule.threat.tactic.id', + 'signal.rule.threat.tactic.name', + 'signal.rule.threat.tactic.reference', + 'signal.rule.threat.technique.id', + 'signal.rule.threat.technique.name', + 'signal.rule.threat.technique.reference', + 'signal.rule.timeline_id', + 'signal.rule.timeline_title', + 'signal.rule.to', + 'signal.rule.type', + 'signal.rule.updated_by', + 'signal.rule.version', + 'signal.status', + ].includes(fieldName); + + return isWhitelistedNonBrowserField || (isAggregatable && isAllowedType); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/translations.ts b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/translations.ts new file mode 100644 index 0000000000000..61d036635a250 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/translations.ts @@ -0,0 +1,33 @@ +/* + * 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 COPY_TO_CLIPBOARD = i18n.translate('xpack.siem.dragAndDrop.copyToClipboardTooltip', { + defaultMessage: 'Copy to Clipboard', +}); + +export const FIELD = i18n.translate('xpack.siem.dragAndDrop.fieldLabel', { + defaultMessage: 'Field', +}); + +export const FILTER_FOR_VALUE = i18n.translate('xpack.siem.dragAndDrop.filterForValueHoverAction', { + defaultMessage: 'Filter for value', +}); + +export const FILTER_OUT_VALUE = i18n.translate('xpack.siem.dragAndDrop.filterOutValueHoverAction', { + defaultMessage: 'Filter out value', +}); + +export const CLOSE = i18n.translate('xpack.siem.dragAndDrop.closeButtonLabel', { + defaultMessage: 'Close', +}); + +export const SHOW_TOP = (fieldName: string) => + i18n.translate('xpack.siem.overview.showTopTooltip', { + values: { fieldName }, + defaultMessage: `Show top {fieldName}`, + }); diff --git a/x-pack/legacy/plugins/siem/public/components/draggables/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/draggables/__snapshots__/index.test.tsx.snap index 63ba13306ecd8..93608a181adff 100644 --- a/x-pack/legacy/plugins/siem/public/components/draggables/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/draggables/__snapshots__/index.test.tsx.snap @@ -10,6 +10,7 @@ exports[`draggables rendering it renders the default Badge 1`] = ` A child of this diff --git a/x-pack/legacy/plugins/siem/public/components/draggables/index.tsx b/x-pack/legacy/plugins/siem/public/components/draggables/index.tsx index 1fe6c936d2823..a0b825ea24441 100644 --- a/x-pack/legacy/plugins/siem/public/components/draggables/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/draggables/index.tsx @@ -167,7 +167,7 @@ export const DraggableBadge = React.memo( tooltipContent={tooltipContent} queryValue={queryValue} > - + {children ? children : value !== '' ? value : getEmptyStringTag()} diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx index cd94a9fdcb5ac..131a3a63bae30 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx @@ -21,7 +21,6 @@ import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; import { ToStringArray } from '../../graphql/types'; -import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; import { ColumnHeaderOptions } from '../../store/timeline/model'; import { DragEffects } from '../drag_and_drop/draggable_wrapper'; import { DroppableWrapper } from '../drag_and_drop/droppable_wrapper'; @@ -35,7 +34,6 @@ import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants'; import { MESSAGE_FIELD_NAME } from '../timeline/body/renderers/constants'; import { FormattedFieldValue } from '../timeline/body/renderers/formatted_field'; import { OnUpdateColumns } from '../timeline/events'; -import { WithHoverActions } from '../with_hover_actions'; import { getIconFromType, getExampleText, getColumnsWithTimestamp } from './helpers'; import * as i18n from './translations'; import { EventFieldsData } from './types'; @@ -172,29 +170,18 @@ export const getColumns = ({ component="span" key={`event-details-value-flex-item-${contextId}-${eventId}-${data.field}-${i}-${value}`} > - - - - - - } - render={() => - data.field === MESSAGE_FIELD_NAME ? ( - - ) : ( - - ) - } - /> + {data.field === MESSAGE_FIELD_NAME ? ( + + ) : ( + + )} ))} diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx index ea2cb661763fa..d210c749dae9c 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx @@ -95,6 +95,7 @@ const EventsViewerComponent: React.FC = ({ }) => { const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); + const { filterManager } = useKibana().services.data.query; const combinedQueries = combineQueries({ config: esQuery.getEsQueryConfig(kibana.services.uiSettings), dataProviders, @@ -168,7 +169,11 @@ const EventsViewerComponent: React.FC = ({ {utilityBar?.(refetch, totalCountMinusDeleted)} - + { }); }); - test('it renders a hover actions panel for the category name', () => { - const wrapper = mount( - - - - ); - expect( - wrapper - .find('[data-test-subj="category-link"]') - .first() - .find('[data-test-subj="hover-actions-panel-container"]') - .first() - .exists() - ).toBe(true); - }); - test('it renders the selected category with bold text', () => { const selectedCategoryId = 'auditd'; diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx index 0c7dd7e908ce3..7133e9b848c5c 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx @@ -6,15 +6,7 @@ /* eslint-disable react/display-name */ -import { - EuiIcon, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiPanel, - EuiText, - EuiToolTip, -} from '@elastic/eui'; +import { EuiIcon, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiToolTip } from '@elastic/eui'; import React, { useContext } from 'react'; import styled from 'styled-components'; @@ -35,22 +27,6 @@ const CategoryName = styled.span<{ bold: boolean }>` CategoryName.displayName = 'CategoryName'; -const HoverActionsContainer = styled(EuiPanel)` - cursor: default; - left: 5px; - padding: 8px; - position: absolute; - top: -8px; -`; - -HoverActionsContainer.displayName = 'HoverActionsContainer'; - -const HoverActionsFlexGroup = styled(EuiFlexGroup)` - cursor: pointer; -`; - -HoverActionsFlexGroup.displayName = 'HoverActionsFlexGroup'; - const LinkContainer = styled.div` width: 100%; .euiLink { @@ -71,7 +47,7 @@ interface ToolTipProps { } const ToolTip = React.memo(({ categoryId, browserFields, onUpdateColumns }) => { - const isLoading = useContext(TimelineContext); + const { isLoading } = useContext(TimelineContext); return ( {!isLoading ? ( @@ -127,25 +103,11 @@ export const getCategoryColumns = ({ - - - - - - + } render={() => ( { expect(wrapper.find('[data-test-subj="copy-to-clipboard"]').exists()).toBe(true); }); - test('it renders a view category action menu item a user hovers over the name', () => { - const wrapper = mount( - - - - ); - - wrapper.simulate('mouseenter'); - wrapper.update(); - expect(wrapper.find('[data-test-subj="view-category"]').exists()).toBe(true); - }); - - test('it invokes onUpdateColumns when the view category action menu item is clicked', () => { - const onUpdateColumns = jest.fn(); - - const wrapper = mount( - - - - ); - - wrapper.simulate('mouseenter'); - wrapper.update(); - wrapper - .find('[data-test-subj="view-category"]') - .first() - .simulate('click'); - - expect(onUpdateColumns).toBeCalledWith([ - { - aggregatable: true, - category: 'base', - columnHeaderType: 'not-filtered', - description: - 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.', - example: '2016-05-23T08:05:34.853Z', - id: '@timestamp', - type: 'date', - width: 190, - }, - ]); - }); - test('it highlights the text specified by the `highlight` prop', () => { const highlight = 'stamp'; diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx index fe434a6ad63ce..fc9633b6f8748 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx @@ -4,26 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiButtonIcon, - EuiHighlight, - EuiIcon, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiText, - EuiToolTip, -} from '@elastic/eui'; -import React, { useContext } from 'react'; +import { EuiButtonIcon, EuiHighlight, EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import React, { useCallback, useContext, useState, useMemo } from 'react'; import styled from 'styled-components'; -import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; import { ColumnHeaderOptions } from '../../store/timeline/model'; import { OnUpdateColumns } from '../timeline/events'; import { TimelineContext } from '../timeline/timeline_context'; import { WithHoverActions } from '../with_hover_actions'; import { LoadingSpinner } from './helpers'; import * as i18n from './translations'; +import { DraggableWrapperHoverContent } from '../drag_and_drop/draggable_wrapper_hover_content'; /** * The name of a (draggable) field @@ -82,22 +73,6 @@ export const FieldNameContainer = styled.span` FieldNameContainer.displayName = 'FieldNameContainer'; -const HoverActionsContainer = styled(EuiPanel)` - cursor: default; - left: 5px; - padding: 4px; - position: absolute; - top: -6px; -`; - -HoverActionsContainer.displayName = 'HoverActionsContainer'; - -const HoverActionsFlexGroup = styled(EuiFlexGroup)` - cursor: pointer; -`; - -HoverActionsFlexGroup.displayName = 'HoverActionsFlexGroup'; - const ViewCategoryIcon = styled(EuiIcon)` margin-left: 5px; `; @@ -112,7 +87,7 @@ interface ToolTipProps { const ViewCategory = React.memo( ({ categoryId, onUpdateColumns, categoryColumns }) => { - const isLoading = useContext(TimelineContext); + const { isLoading } = useContext(TimelineContext); return ( {!isLoading ? ( @@ -142,48 +117,33 @@ export const FieldName = React.memo<{ fieldId: string; highlight?: string; onUpdateColumns: OnUpdateColumns; -}>(({ categoryId, categoryColumns, fieldId, highlight = '', onUpdateColumns }) => ( - - - - - - - - - {categoryColumns.length > 0 && ( - - - - )} - - - } - render={() => ( - - +}>(({ fieldId, highlight = '' }) => { + const [showTopN, setShowTopN] = useState(false); + const toggleTopN = useCallback(() => { + setShowTopN(!showTopN); + }, [setShowTopN, showTopN]); + + const hoverContent = useMemo( + () => ( + + ), + [fieldId, showTopN, toggleTopN] + ); + + const render = useCallback( + () => ( + + {fieldId} - - - )} - /> -)); + + + ), + [fieldId, highlight] + ); + + return ; +}); FieldName.displayName = 'FieldName'; diff --git a/x-pack/legacy/plugins/siem/public/components/header_page/title.tsx b/x-pack/legacy/plugins/siem/public/components/header_page/title.tsx index a1f3cfd857148..59039ddd6a23b 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_page/title.tsx +++ b/x-pack/legacy/plugins/siem/public/components/header_page/title.tsx @@ -51,7 +51,9 @@ const TitleComponent: React.FC = ({ draggableArguments, title, badgeOptio tooltipPosition="bottom" /> ) : ( - {badgeOptions.text} + + {badgeOptions.text} + )} )} diff --git a/x-pack/legacy/plugins/siem/public/components/header_section/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/header_section/__snapshots__/index.test.tsx.snap index d4c3763f51460..53b41e2240de2 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_section/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/header_section/__snapshots__/index.test.tsx.snap @@ -11,13 +11,18 @@ exports[`HeaderSection it renders 1`] = ` responsive={false} > - +

Test title

+
diff --git a/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx index bc4692b6fe0c5..e61b39691203c 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx @@ -48,7 +48,7 @@ describe('HeaderSection', () => { ).toBe(true); }); - test('it DOES NOT render the subtitle when not provided', () => { + test('renders the subtitle when not provided (to prevent layout thrash)', () => { const wrapper = mount( @@ -60,7 +60,7 @@ describe('HeaderSection', () => { .find('[data-test-subj="header-section-subtitle"]') .first() .exists() - ).toBe(false); + ).toBe(true); }); test('it renders supplements when children provided', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx b/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx index 3153e785a8a32..43245121dd393 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiTitle } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiTitle, EuiTitleSize } from '@elastic/eui'; import React from 'react'; import styled, { css } from 'styled-components'; @@ -36,6 +36,7 @@ export interface HeaderSectionProps extends HeaderProps { split?: boolean; subtitle?: string | React.ReactNode; title: string | React.ReactNode; + titleSize?: EuiTitleSize; tooltip?: string; } @@ -46,6 +47,7 @@ const HeaderSectionComponent: React.FC = ({ split, subtitle, title, + titleSize = 'm', tooltip, }) => (
@@ -53,7 +55,7 @@ const HeaderSectionComponent: React.FC = ({ - +

{title} {tooltip && ( @@ -65,7 +67,7 @@ const HeaderSectionComponent: React.FC = ({

- {subtitle && } +
{id && ( diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.test.tsx index d2d1d6569854d..214c0294f2cf4 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.test.tsx @@ -24,6 +24,8 @@ import { ExternalLink, } from '.'; +jest.mock('../../pages/overview/events_by_dataset'); + jest.mock('../../lib/kibana', () => { return { useUiSetting$: jest.fn(), diff --git a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/__snapshots__/index.test.tsx.snap index 0e518e48e2e88..5aa846d15b684 100644 --- a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/__snapshots__/index.test.tsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Matrix Histogram Component not initial load it renders no MatrixLoader 1`] = `"
"`; +exports[`Matrix Histogram Component not initial load it renders no MatrixLoader 1`] = `"
"`; -exports[`Matrix Histogram Component on initial load it renders MatrixLoader 1`] = `"
"`; +exports[`Matrix Histogram Component on initial load it renders MatrixLoader 1`] = `"
"`; diff --git a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.test.tsx index db5b1f7f03ee3..3b8a43a0f395a 100644 --- a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.test.tsx @@ -87,6 +87,17 @@ describe('Matrix Histogram Component', () => { }); }); + describe('spacer', () => { + test('it renders a spacer by default', () => { + expect(wrapper.find('[data-test-subj="spacer"]').exists()).toBe(true); + }); + + test('it does NOT render a spacer when showSpacer is false', () => { + wrapper = mount(); + expect(wrapper.find('[data-test-subj="spacer"]').exists()).toBe(false); + }); + }); + describe('not initial load', () => { beforeAll(() => { (useQuery as jest.Mock).mockReturnValue({ diff --git a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx index 12a474009dc5b..3d4eebd68319c 100644 --- a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx @@ -37,6 +37,7 @@ import { import { SetQuery } from '../../pages/hosts/navigation/types'; import { QueryTemplateProps } from '../../containers/query_template'; import { setAbsoluteRangeDatePicker } from '../../store/inputs/actions'; +import { InputsModelId } from '../../store/inputs/constants'; import { HistogramType } from '../../graphql/types'; export interface OwnProps extends QueryTemplateProps { @@ -46,9 +47,12 @@ export interface OwnProps extends QueryTemplateProps { hideHistogramIfEmpty?: boolean; histogramType: HistogramType; id: string; + indexToAdd?: string[] | null; legendPosition?: Position; mapping?: MatrixHistogramMappingTypes; + showSpacer?: boolean; setQuery: SetQuery; + setAbsoluteRangeDatePickerTarget?: InputsModelId; showLegend?: boolean; stackByOptions: MatrixHistogramOption[]; subtitle?: string | GetSubTitle; @@ -62,6 +66,7 @@ const HeaderChildrenFlexItem = styled(EuiFlexItem)` margin-left: 24px; `; +// @ts-ignore - the EUI type definitions for Panel do no play nice with styled-components const HistogramPanel = styled(Panel)<{ height?: number }>` display: flex; flex-direction: column; @@ -79,16 +84,20 @@ export const MatrixHistogramComponent: React.FC { @@ -100,7 +109,11 @@ export const MatrixHistogramComponent: React.FC { - dispatchSetAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); + dispatchSetAbsoluteRangeDatePicker({ + id: setAbsoluteRangeDatePickerTarget, + from: min, + to: max, + }); }, yTickFormatter, showLegend, @@ -122,7 +135,7 @@ export const MatrixHistogramComponent: React.FC) => { setSelectedStackByOption( - stackByOptions?.find(co => co.value === event.target.value) ?? defaultStackByOption + stackByOptions.find(co => co.value === event.target.value) ?? defaultStackByOption ); }, [] @@ -134,6 +147,7 @@ export const MatrixHistogramComponent: React.FC (title != null && typeof title === 'function' ? title(selectedStackByOption) : title), [title, selectedStackByOption] ); - const subtitleWithCounts = useMemo( - () => (subtitle != null && typeof subtitle === 'function' ? subtitle(totalCount) : subtitle), - [subtitle, totalCount] - ); + const subtitleWithCounts = useMemo(() => { + if (isInitialLoading) { + return null; + } + + if (typeof subtitle === 'function') { + return totalCount >= 0 ? subtitle(totalCount) : null; + } + + return subtitle; + }, [isInitialLoading, subtitle, totalCount]); const hideHistogram = useMemo(() => (totalCount <= 0 && hideHistogramIfEmpty ? true : false), [ totalCount, hideHistogramIfEmpty, @@ -155,7 +176,9 @@ export const MatrixHistogramComponent: React.FC getCustomChartData(data, mapping), [data, mapping]); useEffect(() => { - setQuery({ id, inspect, loading, refetch }); + if (!loading && !isInitialLoading) { + setQuery({ id, inspect, loading, refetch }); + } if (isInitialLoading && !!barChartData && data) { setIsInitialLoading(false); @@ -189,59 +212,39 @@ export const MatrixHistogramComponent: React.FC )} + + + + {stackByOptions.length > 1 && ( + + )} + + {headerChildren} + + + {isInitialLoading ? ( - <> - = 0 ? subtitleWithCounts : null)} - > - - - {stackByOptions?.length > 1 && ( - - )} - - {headerChildren} - - - - + ) : ( - <> - = 0 ? subtitleWithCounts : null) - } - > - - - {stackByOptions?.length > 1 && ( - - )} - - {headerChildren} - - - - + )} - + {showSpacer && } ); }; diff --git a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/types.ts b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/types.ts index a435c7be6c890..c59775ad325d0 100644 --- a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiTitleSize } from '@elastic/eui'; import { ScaleType, Position, TickFormatter } from '@elastic/charts'; import { ActionCreator } from 'redux'; import { ESQuery } from '../../../common/typed_json'; @@ -34,6 +35,7 @@ export interface MatrixHisrogramConfigs { stackByOptions: MatrixHistogramOption[]; subtitle?: string | GetSubTitle; title: string | GetTitle; + titleSize?: EuiTitleSize; } interface MatrixHistogramBasicProps { @@ -57,14 +59,22 @@ interface MatrixHistogramBasicProps { stackByOptions: MatrixHistogramOption[]; subtitle?: string | GetSubTitle; title?: string | GetTitle; + titleSize?: EuiTitleSize; } export interface MatrixHistogramQueryProps { endDate: number; errorMessage: string; filterQuery?: ESQuery | string | undefined; + setAbsoluteRangeDatePicker?: ActionCreator<{ + id: InputsModelId; + from: number; + to: number; + }>; + setAbsoluteRangeDatePickerTarget?: InputsModelId; stackByField: string; startDate: number; + indexToAdd?: string[] | null; isInspected: boolean; histogramType: HistogramType; } @@ -73,6 +83,7 @@ export interface MatrixHistogramProps extends MatrixHistogramBasicProps { scaleType?: ScaleType; yTickFormatter?: (value: number) => string; showLegend?: boolean; + showSpacer?: boolean; legendPosition?: Position; } diff --git a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/utils.ts b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/utils.ts index ddac615cef50a..d31eb1da15ea1 100644 --- a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/utils.ts +++ b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/utils.ts @@ -69,6 +69,19 @@ export const getBarchartConfigs = ({ customHeight: chartHeight ?? DEFAULT_CHART_HEIGHT, }); +export const defaultLegendColors = [ + '#1EA593', + '#2B70F7', + '#CE0060', + '#38007E', + '#FCA5D3', + '#F37020', + '#E49E29', + '#B0916F', + '#7B000B', + '#34130C', +]; + export const formatToChartDataItem = ([key, value]: [ string, MatrixOverTimeHistogramData[] diff --git a/x-pack/legacy/plugins/siem/public/components/notes/note_card/note_card_body.tsx b/x-pack/legacy/plugins/siem/public/components/notes/note_card/note_card_body.tsx index 11761c8fd39b0..4463f8d4ff602 100644 --- a/x-pack/legacy/plugins/siem/public/components/notes/note_card/note_card_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/notes/note_card/note_card_body.tsx @@ -5,7 +5,7 @@ */ import { EuiPanel, EuiToolTip } from '@elastic/eui'; -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { WithCopyToClipboard } from '../../../lib/clipboard/with_copy_to_clipboard'; @@ -19,33 +19,23 @@ const BodyContainer = styled(EuiPanel)` BodyContainer.displayName = 'BodyContainer'; -const HoverActionsContainer = styled(EuiPanel)` - align-items: center; - display: flex; - flex-direction: row; - height: 25px; - justify-content: center; - left: 5px; - position: absolute; - top: -5px; - width: 30px; -`; - -HoverActionsContainer.displayName = 'HoverActionsContainer'; - -export const NoteCardBody = React.memo<{ rawNote: string }>(({ rawNote }) => ( - - - - - - - } - render={() => } - /> - -)); +export const NoteCardBody = React.memo<{ rawNote: string }>(({ rawNote }) => { + const hoverContent = useMemo( + () => ( + + + + ), + [rawNote] + ); + + const render = useCallback(() => , [rawNote]); + + return ( + + + + ); +}); NoteCardBody.displayName = 'NoteCardBody'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/__snapshots__/index.test.tsx.snap index 42ef4e5404faa..ef02311c0629e 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/__snapshots__/index.test.tsx.snap @@ -3,22 +3,36 @@ exports[`AddFilterToGlobalSearchBar Component Rendering 1`] = ` - - + + + + } render={[Function]} /> diff --git a/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/helpers.test.tsx index 7e5e53f575be8..5c920d923d9a4 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/helpers.test.tsx @@ -24,6 +24,22 @@ describe('helpers', () => { }); }); + test('returns a negated filter when `negate` is true', () => { + const filter = createFilter('host.name', 'siem-xavier', true); + expect(filter).toEqual({ + meta: { + alias: null, + disabled: false, + key: 'host.name', + negate: true, // <-- filter is negated + params: { query: 'siem-xavier' }, + type: 'phrase', + value: 'siem-xavier', + }, + query: { match: { 'host.name': { query: 'siem-xavier', type: 'phrase' } } }, + }); + }); + test('return valid exists filter when valid key and null value are provided', () => { const filter = createFilter('host.name', null); expect(filter).toEqual({ diff --git a/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/helpers.ts b/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/helpers.ts index bafe033368c83..d88bc2bf3b7e6 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/helpers.ts @@ -6,13 +6,17 @@ import { Filter } from '../../../../../../../../src/plugins/data/public'; -export const createFilter = (key: string, value: string[] | string | null | undefined): Filter => { +export const createFilter = ( + key: string, + value: string[] | string | null | undefined, + negate: boolean = false +): Filter => { const queryValue = value != null ? (Array.isArray(value) ? value[0] : value) : null; return queryValue != null ? { meta: { alias: null, - negate: false, + negate, disabled: false, type: 'phrase', key, diff --git a/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/index.tsx index 160cd020796db..127eb3bae0284 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/index.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import React, { useCallback } from 'react'; -import styled from 'styled-components'; import { Filter } from '../../../../../../../../src/plugins/data/public'; import { WithHoverActions } from '../../with_hover_actions'; @@ -26,21 +25,52 @@ export const AddFilterToGlobalSearchBar = React.memo( ({ children, filter, onFilterAdded }) => { const { filterManager } = useKibana().services.data.query; - const addToKql = useCallback(() => { + const filterForValue = useCallback(() => { filterManager.addFilters(filter); + if (onFilterAdded != null) { onFilterAdded(); } - }, [filter, filterManager, onFilterAdded]); + }, [filterManager, filter, onFilterAdded]); + + const filterOutValue = useCallback(() => { + filterManager.addFilters({ + ...filter, + meta: { + ...filter.meta, + negate: true, + }, + }); + + if (onFilterAdded != null) { + onFilterAdded(); + } + }, [filterManager, filter, onFilterAdded]); return ( +
- + + + + + - +
} render={() => children} /> @@ -49,16 +79,3 @@ export const AddFilterToGlobalSearchBar = React.memo( ); AddFilterToGlobalSearchBar.displayName = 'AddFilterToGlobalSearchBar'; - -export const HoverActionsContainer = styled(EuiPanel)` - align-items: center; - display: flex; - flex-direction: row; - height: 34px; - justify-content: center; - left: 5px; - position: absolute; - top: -10px; - width: 34px; - cursor: pointer; -`; diff --git a/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/translations.ts b/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/translations.ts index 81772527e59db..f192c5c26fa49 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/add_filter_to_global_search_bar/translations.ts @@ -12,3 +12,10 @@ export const FILTER_FOR_VALUE = i18n.translate( defaultMessage: 'Filter for value', } ); + +export const FILTER_OUT_VALUE = i18n.translate( + 'xpack.siem.add_filter_to_global_search_bar.filterOutValueHoverAction', + { + defaultMessage: 'Filter out value', + } +); diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/columns.tsx index 8d490d2c152d9..6bd82f3192f9b 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/columns.tsx @@ -46,9 +46,7 @@ export const getHostsColumns = (): HostsTableColumns => [ ) : ( - - - + ) } /> diff --git a/x-pack/legacy/plugins/siem/public/components/page/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/index.tsx index ef6a19f4b7448..3a36a2dce476b 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/index.tsx @@ -17,7 +17,7 @@ export const AppGlobalStyle = createGlobalStyle` position: static; } /* end of dirty hack to fix draggables with tooltip on FF */ - + div.app-wrapper { background-color: rgba(0,0,0,0); } @@ -28,12 +28,13 @@ export const AppGlobalStyle = createGlobalStyle` .euiPopover__panel.euiPopover__panel-isOpen { z-index: 9900 !important; + min-width: 24px; } .euiToolTip { z-index: 9950 !important; } - /* + /* overrides the default styling of euiComboBoxOptionsList because it's implemented as a popover, so it's not selectable as a child of the styled component */ @@ -45,6 +46,17 @@ export const AppGlobalStyle = createGlobalStyle` .euiPanel-loading-hide-border { border: none; } + + /* hide open popovers when a modal is being displayed to prevent them from covering the modal */ + body.euiBody-hasOverlayMask .euiPopover__panel-isOpen { + visibility: hidden !important; + } + + /* ensure elastic charts tooltips appear above open euiPopovers */ + .echTooltip { + z-index: 9950; + } + `; export const DescriptionListStyled = styled(EuiDescriptionList)` diff --git a/x-pack/legacy/plugins/siem/public/components/page/manage_query.tsx b/x-pack/legacy/plugins/siem/public/components/page/manage_query.tsx index 138c38c02065b..3b723c66f5af5 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/manage_query.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/manage_query.tsx @@ -42,6 +42,6 @@ export function manageQuery(WrappedComponent: React.ComponentClass | React return ; } } - ManageQuery.displayName = `ManageQuery (${WrappedComponent.displayName || 'Unknown'})`; + ManageQuery.displayName = `ManageQuery (${WrappedComponent?.displayName ?? 'Unknown'})`; return ManageQuery; } diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx index 5cd200cbb41b7..73c3d2da184e7 100644 --- a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { EuiBasicTable, EuiBasicTableProps, @@ -246,21 +247,16 @@ const PaginatedTableComponent: FC = ({ ) : ( <> - { - // @ts-ignore avoid some type mismatches - } = ({ export const PaginatedTable = memo(PaginatedTableComponent); type BasicTableType = ComponentType>; // eslint-disable-line @typescript-eslint/no-explicit-any -const BasicTable: typeof EuiBasicTable & { displayName: string } = styled( - EuiBasicTable as BasicTableType -)` +const BasicTable = styled(EuiBasicTable as BasicTableType)` tbody { th, td { diff --git a/x-pack/legacy/plugins/siem/public/components/source_destination/source_destination_ip.tsx b/x-pack/legacy/plugins/siem/public/components/source_destination/source_destination_ip.tsx index b8192cce11e5a..62f01dfc020f5 100644 --- a/x-pack/legacy/plugins/siem/public/components/source_destination/source_destination_ip.tsx +++ b/x-pack/legacy/plugins/siem/public/components/source_destination/source_destination_ip.tsx @@ -171,7 +171,7 @@ export const SourceDestinationIp = React.memo( return isIpFieldPopulated({ destinationIp, sourceIp, type }) || hasPorts({ destinationPort, sourcePort, type }) ? ( - + (({ header, onColumnRemoved, sort }) => { - const isLoading = useTimelineContext(); + const { isLoading } = useTimelineContext(); return ( <> {sort.columnId === header.id && isLoading ? ( diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx index 84781e6a24300..0a69cef618570 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx @@ -32,7 +32,7 @@ const HeaderContentComponent: React.FC = ({ onClick, sort, }) => { - const isLoading = useTimelineContext(); + const { isLoading } = useTimelineContext(); return ( diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/auditd/generic_row_renderer.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/auditd/generic_row_renderer.test.tsx index 5f59915eac418..417a078a08150 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/auditd/generic_row_renderer.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/auditd/generic_row_renderer.test.tsx @@ -19,6 +19,8 @@ import { createGenericFileRowRenderer, } from './generic_row_renderer'; +jest.mock('../../../../../pages/overview/events_by_dataset'); + describe('GenericRowRenderer', () => { const mount = useMountAppended(); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/suricata/suricata_signature.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/suricata/suricata_signature.tsx index 9ccd1fb7a0519..24c52f3372d62 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/suricata/suricata_signature.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/suricata/suricata_signature.tsx @@ -44,7 +44,7 @@ export const Tokens = React.memo<{ tokens: string[] }>(({ tokens }) => ( <> {tokens.map(token => ( - + {token} @@ -81,7 +81,7 @@ export const DraggableSignatureId = React.memo<{ id: string; signatureId: number data-test-subj="signature-id-tooltip" content={SURICATA_SIGNATURE_ID_FIELD_NAME} > - + {signatureId} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_details.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_details.tsx index e1524c8e5aecb..2ad3eb4681454 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_details.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_details.tsx @@ -119,7 +119,7 @@ export const SystemGenericLine = React.memo( - + diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_file_details.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_file_details.tsx index c47d9603cbea2..ef7c3b3ccf859 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_file_details.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_file_details.tsx @@ -193,7 +193,7 @@ export const SystemGenericFileLine = React.memo( - + diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_row_renderer.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_row_renderer.test.tsx index abe77a63f4a27..2f5fa76855f2b 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_row_renderer.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/system/generic_row_renderer.test.tsx @@ -48,6 +48,8 @@ import { } from './generic_row_renderer'; import * as i18n from './translations'; +jest.mock('../../../../../pages/overview/events_by_dataset'); + describe('GenericRowRenderer', () => { const mount = useMountAppended(); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/zeek/zeek_signature.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/zeek/zeek_signature.tsx index f13a236e8ec36..39c21c4ffa33b 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/zeek/zeek_signature.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/renderers/zeek/zeek_signature.tsx @@ -92,7 +92,7 @@ export const DraggableZeekElement = React.memo<{ ) : ( - + {stringRenderer(value)} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/index.tsx index 525cc8e301d11..f369b961807af 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/index.tsx @@ -109,7 +109,7 @@ export const DataProviders = React.memo( data-test-subj="dataProviders" > - {isLoading => ( + {({ isLoading }) => ( {dataProviders != null && dataProviders.length ? ( ( ({ deleteProvider, field, isEnabled, isExcluded, operator, providerId, togglePopover, val }) => { - const deleteFilter: React.MouseEventHandler = ( - event: React.MouseEvent - ) => { - // Make sure it doesn't also trigger the onclick for the whole badge - if (event.stopPropagation) { - event.stopPropagation(); - } - deleteProvider(); - }; - const classes = classNames('globalFilterItem', { - 'globalFilterItem-isDisabled': !isEnabled, - 'globalFilterItem-isExcluded': isExcluded, - }); - const formattedValue = isString(val) && val === '' ? getEmptyString() : val; - const prefix = isExcluded ? {i18n.NOT} : null; - const title = `${field}: "${formattedValue}"`; - - return ( - - {prefix} - {operator !== EXISTS_OPERATOR ? ( - <> - {`${field}: `} - {`"${formattedValue}"`} - - ) : ( - - {field} {i18n.EXISTS_LABEL} - - )} - + const deleteFilter: React.MouseEventHandler = useCallback( + (event: React.MouseEvent) => { + // Make sure it doesn't also trigger the onclick for the whole badge + if (event.stopPropagation) { + event.stopPropagation(); + } + deleteProvider(); + }, + [deleteProvider] + ); + + const classes = useMemo( + () => + classNames('globalFilterItem', { + 'globalFilterItem-isDisabled': !isEnabled, + 'globalFilterItem-isExcluded': isExcluded, + }), + [isEnabled, isExcluded] + ); + + const formattedValue = useMemo(() => (isString(val) && val === '' ? getEmptyString() : val), [ + val, + ]); + + const prefix = useMemo(() => (isExcluded ? {i18n.NOT} : null), [isExcluded]); + + const title = useMemo(() => `${field}: "${formattedValue}"`, [field, formattedValue]); + + const hoverContent = useMemo( + () => ( + + ), + [field, val] ); + + const badge = useCallback( + () => ( + + {prefix} + {operator !== EXISTS_OPERATOR ? ( + <> + {`${field}: `} + {`"${formattedValue}"`} + + ) : ( + + {field} {i18n.EXISTS_LABEL} + + )} + + ), + [ + providerId, + field, + val, + classes, + title, + deleteFilter, + togglePopover, + formattedValue, + closeButtonProps, + prefix, + operator, + ] + ); + + return ; } ); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx index 79f9c32a176f5..2cc19537d6a63 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx @@ -71,7 +71,7 @@ export const ProviderItemBadge = React.memo( return ( - {isLoading => ( + {({ isLoading }) => ( { - const mockTimelineContext: boolean = true; + const isLoading: boolean = true; const mount = useMountAppended(); + const filterManager = new FilterManager(mockUiSettingsForFilterManager); describe('rendering', () => { test('renders correctly against snapshot', () => { @@ -96,7 +101,7 @@ describe('Providers', () => { const mockOnDataProviderRemoved = jest.fn(); const wrapper = mount( - + { const mockOnDataProviderRemoved = jest.fn(); const wrapper = mount( - + { const mockOnToggleDataProviderEnabled = jest.fn(); const wrapper = mount( - + { const wrapper = mount( - + { const wrapper = mount( - + { const wrapper = mount( - + { const wrapper = mount( - + { @@ -26,6 +30,7 @@ describe('Header', () => { { { = ({ id, indexPattern, dataProviders, + filterManager, onChangeDataProviderKqlQuery, onChangeDroppableAndProvider, onDataProviderEdited, @@ -77,6 +79,7 @@ const TimelineHeaderComponent: React.FC = ({ /> @@ -90,6 +93,7 @@ export const TimelineHeader = React.memo( prevProps.id === nextProps.id && deepEqual(prevProps.indexPattern, nextProps.indexPattern) && deepEqual(prevProps.dataProviders, nextProps.dataProviders) && + prevProps.filterManager === nextProps.filterManager && prevProps.onChangeDataProviderKqlQuery === nextProps.onChangeDataProviderKqlQuery && prevProps.onChangeDroppableAndProvider === nextProps.onChangeDroppableAndProvider && prevProps.onDataProviderEdited === nextProps.onDataProviderEdited && diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.test.tsx index b978ef3d478d8..943133dc2063c 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.test.tsx @@ -11,12 +11,16 @@ import { DEFAULT_FROM, DEFAULT_TO } from '../../../../common/constants'; import { mockBrowserFields } from '../../../containers/source/mock'; import { convertKueryToElasticSearchQuery } from '../../../lib/keury'; import { mockIndexPattern, TestProviders } from '../../../mock'; +import { createKibanaCoreStartMock } from '../../../mock/kibana_core'; import { QueryBar } from '../../query_bar'; +import { FilterManager } from '../../../../../../../../src/plugins/data/public'; import { mockDataProviders } from '../data_providers/mock/mock_data_providers'; import { buildGlobalQuery } from '../helpers'; import { QueryBarTimeline, QueryBarTimelineComponentProps, getDataProviderFilter } from './index'; +const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; + jest.mock('../../../lib/kibana'); describe('Timeline QueryBar ', () => { @@ -58,6 +62,7 @@ describe('Timeline QueryBar ', () => { browserFields={mockBrowserFields} dataProviders={mockDataProviders} filters={[]} + filterManager={new FilterManager(mockUiSettingsForFilterManager)} filterQuery={{ expression: 'here: query', kind: 'kuery' }} filterQueryDraft={{ expression: 'here: query', kind: 'kuery' }} from={0} @@ -99,6 +104,7 @@ describe('Timeline QueryBar ', () => { browserFields={mockBrowserFields} dataProviders={mockDataProviders} filters={[]} + filterManager={new FilterManager(mockUiSettingsForFilterManager)} filterQuery={{ expression: 'here: query', kind: 'kuery' }} filterQueryDraft={{ expression: 'here: query', kind: 'kuery' }} from={0} @@ -145,6 +151,7 @@ describe('Timeline QueryBar ', () => { browserFields={mockBrowserFields} dataProviders={mockDataProviders} filters={[]} + filterManager={new FilterManager(mockUiSettingsForFilterManager)} filterQuery={{ expression: 'here: query', kind: 'kuery' }} filterQueryDraft={{ expression: 'here: query', kind: 'kuery' }} from={0} @@ -189,6 +196,7 @@ describe('Timeline QueryBar ', () => { browserFields={mockBrowserFields} dataProviders={mockDataProviders} filters={[]} + filterManager={new FilterManager(mockUiSettingsForFilterManager)} filterQuery={{ expression: 'here: query', kind: 'kuery' }} filterQueryDraft={{ expression: 'here: query', kind: 'kuery' }} from={0} @@ -235,6 +243,7 @@ describe('Timeline QueryBar ', () => { browserFields={mockBrowserFields} dataProviders={mockDataProviders} filters={[]} + filterManager={new FilterManager(mockUiSettingsForFilterManager)} filterQuery={{ expression: 'here: query', kind: 'kuery' }} filterQueryDraft={{ expression: 'here: query', kind: 'kuery' }} from={0} @@ -279,6 +288,7 @@ describe('Timeline QueryBar ', () => { browserFields={mockBrowserFields} dataProviders={mockDataProviders} filters={[]} + filterManager={new FilterManager(mockUiSettingsForFilterManager)} filterQuery={{ expression: 'here: query', kind: 'kuery' }} filterQueryDraft={{ expression: 'here: query', kind: 'kuery' }} from={0} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.tsx index 7f662cdb2f1b4..f53f7bb56e2f4 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.tsx @@ -21,7 +21,6 @@ import { import { BrowserFields } from '../../../containers/source'; import { convertKueryToElasticSearchQuery } from '../../../lib/keury'; -import { useKibana } from '../../../lib/kibana'; import { KueryFilterQuery, KueryFilterQueryKind } from '../../../store'; import { KqlMode } from '../../../store/timeline/model'; import { useSavedQueryServices } from '../../../utils/saved_query_services'; @@ -35,6 +34,7 @@ export interface QueryBarTimelineComponentProps { browserFields: BrowserFields; dataProviders: DataProvider[]; filters: Filter[]; + filterManager: FilterManager; filterQuery: KueryFilterQuery; filterQueryDraft: KueryFilterQuery; from: number; @@ -61,6 +61,7 @@ export const QueryBarTimeline = memo( browserFields, dataProviders, filters, + filterManager, filterQuery, filterQueryDraft, from, @@ -94,9 +95,6 @@ export const QueryBarTimeline = memo( const [dataProvidersDsl, setDataProvidersDsl] = useState( convertKueryToElasticSearchQuery(buildGlobalQuery(dataProviders, browserFields), indexPattern) ); - const kibana = useKibana(); - const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); - const savedQueryServices = useSavedQueryServices(); useEffect(() => { diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx index 87061bdbb5d02..a0a0ac4c2b85c 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx @@ -10,7 +10,11 @@ import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; import deepEqual from 'fast-deep-equal'; -import { Filter, IIndexPattern } from '../../../../../../../../src/plugins/data/public'; +import { + Filter, + FilterManager, + IIndexPattern, +} from '../../../../../../../../src/plugins/data/public'; import { BrowserFields } from '../../../containers/source'; import { convertKueryToElasticSearchQuery } from '../../../lib/keury'; import { @@ -29,6 +33,7 @@ import { SearchOrFilter } from './search_or_filter'; interface OwnProps { browserFields: BrowserFields; + filterManager: FilterManager; indexPattern: IIndexPattern; timelineId: string; } @@ -42,6 +47,7 @@ const StatefulSearchOrFilterComponent = React.memo( dataProviders, eventType, filters, + filterManager, filterQuery, filterQueryDraft, from, @@ -122,6 +128,7 @@ const StatefulSearchOrFilterComponent = React.memo( dataProviders={dataProviders} eventType={eventType} filters={filters} + filterManager={filterManager} filterQuery={filterQuery} filterQueryDraft={filterQueryDraft} from={from} @@ -146,6 +153,7 @@ const StatefulSearchOrFilterComponent = React.memo( (prevProps, nextProps) => { return ( prevProps.eventType === nextProps.eventType && + prevProps.filterManager === nextProps.filterManager && prevProps.from === nextProps.from && prevProps.fromStr === nextProps.fromStr && prevProps.to === nextProps.to && diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/search_or_filter.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/search_or_filter.tsx index 7bdd92e745f21..02a575db259bb 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/search_or_filter.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/search_or_filter.tsx @@ -8,7 +8,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiSuperSelect, EuiToolTip } from '@elastic/ import React from 'react'; import styled, { createGlobalStyle } from 'styled-components'; -import { Filter, IIndexPattern } from '../../../../../../../../src/plugins/data/public'; +import { + Filter, + FilterManager, + IIndexPattern, +} from '../../../../../../../../src/plugins/data/public'; import { BrowserFields } from '../../../containers/source'; import { KueryFilterQuery, KueryFilterQueryKind } from '../../../store'; import { KqlMode, EventType } from '../../../store/timeline/model'; @@ -44,6 +48,7 @@ interface Props { browserFields: BrowserFields; dataProviders: DataProvider[]; eventType: EventType; + filterManager: FilterManager; filterQuery: KueryFilterQuery; filterQueryDraft: KueryFilterQuery; from: number; @@ -95,6 +100,7 @@ export const SearchOrFilter = React.memo( indexPattern, isRefreshPaused, filters, + filterManager, filterQuery, filterQueryDraft, from, @@ -135,6 +141,7 @@ export const SearchOrFilter = React.memo( browserFields={browserFields} dataProviders={dataProviders} filters={filters} + filterManager={filterManager} filterQuery={filterQuery} filterQueryDraft={filterQueryDraft} from={from} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx index 098dd82791610..222cc0530bddb 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx @@ -6,7 +6,7 @@ import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui'; import { getOr, isEmpty } from 'lodash/fp'; -import React, { useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import styled from 'styled-components'; import { FlyoutHeaderWithCloseButton } from '../flyout/header_with_close_button'; @@ -34,7 +34,12 @@ import { TimelineHeader } from './header'; import { combineQueries } from './helpers'; import { TimelineRefetch } from './refetch_timeline'; import { ManageTimelineContext } from './timeline_context'; -import { esQuery, Filter, IIndexPattern } from '../../../../../../../src/plugins/data/public'; +import { + esQuery, + Filter, + FilterManager, + IIndexPattern, +} from '../../../../../../../src/plugins/data/public'; const TimelineContainer = styled.div` height: 100%; @@ -143,6 +148,7 @@ export const TimelineComponent: React.FC = ({ usersViewing, }) => { const kibana = useKibana(); + const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); const combinedQueries = combineQueries({ config: esQuery.getEsQueryConfig(kibana.services.uiSettings), dataProviders, @@ -178,6 +184,7 @@ export const TimelineComponent: React.FC = ({ id={id} indexPattern={indexPattern} dataProviders={dataProviders} + filterManager={filterManager} onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery} onChangeDroppableAndProvider={onChangeDroppableAndProvider} onDataProviderEdited={onDataProviderEdited} @@ -211,7 +218,12 @@ export const TimelineComponent: React.FC = ({ getUpdatedAt, refetch, }) => ( - + (initTimelineContext); +interface TimelineContextState { + filterManager: FilterManager | undefined; + isLoading: boolean; +} + +const initTimelineContext: TimelineContextState = { filterManager: undefined, isLoading: false }; +export const TimelineContext = createContext(initTimelineContext); export const useTimelineContext = () => useContext(TimelineContext); export interface TimelineTypeContextProps { documentType?: string; footerText?: string; + id?: string; + indexToAdd?: string[] | null; loadingText?: string; queryFields?: string[]; selectAll?: boolean; @@ -24,6 +34,8 @@ export interface TimelineTypeContextProps { const initTimelineType: TimelineTypeContextProps = { documentType: undefined, footerText: undefined, + id: undefined, + indexToAdd: undefined, loadingText: undefined, queryFields: [], selectAll: false, @@ -36,6 +48,8 @@ export const useTimelineTypeContext = () => useContext(TimelineTypeContext); interface ManageTimelineContextProps { children: React.ReactNode; + filterManager: FilterManager; + indexToAdd?: string[] | null; loading: boolean; type?: TimelineTypeContextProps; } @@ -44,22 +58,27 @@ interface ManageTimelineContextProps { // to avoid so many Context, at least the separation of code is there now const ManageTimelineContextComponent: React.FC = ({ children, + filterManager, + indexToAdd, loading, - type = initTimelineType, + type = { ...initTimelineType, indexToAdd }, }) => { - const [myLoading, setLoading] = useState(initTimelineContext); - const [myType, setType] = useState(initTimelineType); + const [myContextState, setMyContextState] = useState({ + filterManager, + isLoading: false, + }); + const [myType, setType] = useState(type); useEffect(() => { - setLoading(loading); - }, [loading]); + setMyContextState({ filterManager, isLoading: loading }); + }, [setMyContextState, filterManager, loading]); useEffect(() => { - setType(type); - }, [type]); + setType({ ...type, indexToAdd }); + }, [type, indexToAdd]); return ( - + {children} ); diff --git a/x-pack/legacy/plugins/siem/public/components/top_n/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/components/top_n/helpers.test.tsx new file mode 100644 index 0000000000000..da0f6f59b533f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/top_n/helpers.test.tsx @@ -0,0 +1,25 @@ +/* + * 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 { allEvents, defaultOptions, getOptions, rawEvents, signalEvents } from './helpers'; + +describe('getOptions', () => { + test(`it returns the default options when 'activeTimelineEventType' is undefined`, () => { + expect(getOptions()).toEqual(defaultOptions); + }); + + test(`it returns 'allEvents' when 'activeTimelineEventType' is 'all'`, () => { + expect(getOptions('all')).toEqual(allEvents); + }); + + test(`it returns 'rawEvents' when 'activeTimelineEventType' is 'raw'`, () => { + expect(getOptions('raw')).toEqual(rawEvents); + }); + + test(`it returns 'signalEvents' when 'activeTimelineEventType' is 'signal'`, () => { + expect(getOptions('signal')).toEqual(signalEvents); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/top_n/helpers.ts b/x-pack/legacy/plugins/siem/public/components/top_n/helpers.ts new file mode 100644 index 0000000000000..8d9ae48d29b69 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/top_n/helpers.ts @@ -0,0 +1,66 @@ +/* + * 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 { EventType } from '../../store/timeline/model'; + +import * as i18n from './translations'; + +export interface TopNOption { + inputDisplay: string; + value: EventType; + 'data-test-subj': string; +} + +/** A (stable) array containing only the 'All events' option */ +export const allEvents: TopNOption[] = [ + { + value: 'all', + inputDisplay: i18n.ALL_EVENTS, + 'data-test-subj': 'option-all', + }, +]; + +/** A (stable) array containing only the 'Raw events' option */ +export const rawEvents: TopNOption[] = [ + { + value: 'raw', + inputDisplay: i18n.RAW_EVENTS, + 'data-test-subj': 'option-raw', + }, +]; + +/** A (stable) array containing only the 'Signal events' option */ +export const signalEvents: TopNOption[] = [ + { + value: 'signal', + inputDisplay: i18n.SIGNAL_EVENTS, + 'data-test-subj': 'option-signal', + }, +]; + +/** A (stable) array containing the default Top N options */ +export const defaultOptions = [...rawEvents, ...signalEvents]; + +/** + * Returns the options to be displayed in a Top N view select. When + * an `activeTimelineEventType` is provided, an array containing + * just one option (corresponding to `activeTimelineEventType`) + * will be returned, to ensure the data displayed in the Top N + * is always in sync with the `EventType` chosen by the user in + * the active timeline. + */ +export const getOptions = (activeTimelineEventType?: EventType): TopNOption[] => { + switch (activeTimelineEventType) { + case 'all': + return allEvents; + case 'raw': + return rawEvents; + case 'signal': + return signalEvents; + default: + return defaultOptions; + } +}; diff --git a/x-pack/legacy/plugins/siem/public/components/top_n/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/top_n/index.test.tsx new file mode 100644 index 0000000000000..61772f1dc7a7a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/top_n/index.test.tsx @@ -0,0 +1,379 @@ +/* + * 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 { mount, ReactWrapper } from 'enzyme'; +import React from 'react'; + +import { mockBrowserFields } from '../../containers/source/mock'; +import { apolloClientObservable, mockGlobalState, TestProviders } from '../../mock'; +import { createKibanaCoreStartMock } from '../../mock/kibana_core'; +import { FilterManager } from '../../../../../../../src/plugins/data/public'; +import { createStore, State } from '../../store'; +import { TimelineContext, TimelineTypeContext } from '../timeline/timeline_context'; + +import { Props } from './top_n'; +import { ACTIVE_TIMELINE_REDUX_ID, StatefulTopN } from '.'; + +jest.mock('../../lib/kibana'); + +const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; + +const field = 'process.name'; +const value = 'nice'; + +const state: State = { + ...mockGlobalState, + inputs: { + ...mockGlobalState.inputs, + global: { + ...mockGlobalState.inputs.global, + query: { + query: 'host.name : end*', + language: 'kuery', + }, + filters: [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'host.os.name', + params: { + query: 'Linux', + }, + }, + query: { + match: { + 'host.os.name': { + query: 'Linux', + type: 'phrase', + }, + }, + }, + }, + ], + }, + timeline: { + ...mockGlobalState.inputs.timeline, + timerange: { + kind: 'relative', + fromStr: 'now-24h', + toStr: 'now', + from: 1586835969047, + to: 1586922369047, + }, + }, + }, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + [ACTIVE_TIMELINE_REDUX_ID]: { + ...mockGlobalState.timeline.timelineById.test, + id: ACTIVE_TIMELINE_REDUX_ID, + dataProviders: [ + { + id: + 'draggable-badge-default-draggable-netflow-renderer-timeline-1-_qpBe3EBD7k-aQQL7v7--_qpBe3EBD7k-aQQL7v7--network_transport-tcp', + name: 'tcp', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'network.transport', + value: 'tcp', + operator: ':', + }, + and: [], + }, + ], + eventType: 'all', + filters: [ + { + meta: { + alias: null, + disabled: false, + key: 'source.port', + negate: false, + params: { + query: '30045', + }, + type: 'phrase', + }, + query: { + match: { + 'source.port': { + query: '30045', + type: 'phrase', + }, + }, + }, + }, + ], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { + kind: 'kuery', + expression: 'host.name : *', + }, + serializedQuery: + '{"bool":{"should":[{"exists":{"field":"host.name"}}],"minimum_should_match":1}}', + }, + filterQueryDraft: { + kind: 'kuery', + expression: 'host.name : *', + }, + }, + }, + }, + }, +}; +const store = createStore(state, apolloClientObservable); + +describe('StatefulTopN', () => { + // Suppress warnings about "react-beautiful-dnd" + /* eslint-disable no-console */ + const originalError = console.error; + const originalWarn = console.warn; + beforeAll(() => { + console.warn = jest.fn(); + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + console.warn = originalWarn; + }); + + describe('rendering in a global NON-timeline context', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + wrapper = mount( + + + + ); + }); + + test('it has undefined combinedQueries when rendering in a global context', () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.combinedQueries).toBeUndefined(); + }); + + test(`defaults to the 'Raw events' view when rendering in a global context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.defaultView).toEqual('raw'); + }); + + test(`provides a 'deleteQuery' when rendering in a global context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.deleteQuery).toBeDefined(); + }); + + test(`provides filters from Redux state (inputs > global > filters) when rendering in a global context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.filters).toEqual([ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'host.os.name', + params: { query: 'Linux' }, + }, + query: { match: { 'host.os.name': { query: 'Linux', type: 'phrase' } } }, + }, + ]); + }); + + test(`provides 'from' via GlobalTime when rendering in a global context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.from).toEqual(0); + }); + + test('provides the global query from Redux state (inputs > global > query) when rendering in a global context', () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.query).toEqual({ query: 'host.name : end*', language: 'kuery' }); + }); + + test(`provides a 'global' 'setAbsoluteRangeDatePickerTarget' when rendering in a global context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.setAbsoluteRangeDatePickerTarget).toEqual('global'); + }); + + test(`provides 'to' via GlobalTime when rendering in a global context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.to).toEqual(1); + }); + }); + + describe('rendering in a timeline context', () => { + let filterManager: FilterManager; + let wrapper: ReactWrapper; + + beforeEach(() => { + filterManager = new FilterManager(mockUiSettingsForFilterManager); + + wrapper = mount( + + + + + + + + ); + }); + + test('it has a combinedQueries value from Redux state composed of the timeline [data providers + kql + filter-bar-filters] when rendering in a timeline context', () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.combinedQueries).toEqual( + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"network.transport":"tcp"}}],"minimum_should_match":1}},{"bool":{"should":[{"exists":{"field":"host.name"}}],"minimum_should_match":1}}]}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1586835969047}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1586922369047}}}],"minimum_should_match":1}}]}}]}},{"match_phrase":{"source.port":{"query":"30045"}}}],"should":[],"must_not":[]}}' + ); + }); + + test('it provides only one view option that matches the `eventType` from redux when rendering in the context of the active timeline', () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.defaultView).toEqual('all'); + }); + + test(`provides an undefined 'deleteQuery' when rendering in a timeline context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.deleteQuery).toBeUndefined(); + }); + + test(`provides empty filters when rendering in a timeline context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.filters).toEqual([]); + }); + + test(`provides 'from' via redux state (inputs > timeline > timerange) when rendering in a timeline context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.from).toEqual(1586835969047); + }); + + test('provides an empty query when rendering in a timeline context', () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.query).toEqual({ query: '', language: 'kuery' }); + }); + + test(`provides a 'timeline' 'setAbsoluteRangeDatePickerTarget' when rendering in a timeline context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.setAbsoluteRangeDatePickerTarget).toEqual('timeline'); + }); + + test(`provides 'to' via redux state (inputs > timeline > timerange) when rendering in a timeline context`, () => { + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.to).toEqual(1586922369047); + }); + }); + + test(`defaults to the 'Signals events' option when rendering in a NON-active timeline context (e.g. the Signals table on the Detections page) when 'documentType' from 'useTimelineTypeContext()' is 'signals'`, () => { + const filterManager = new FilterManager(mockUiSettingsForFilterManager); + const wrapper = mount( + + + + + + + + ); + + const props = wrapper + .find('[data-test-subj="top-n"]') + .first() + .props() as Props; + + expect(props.defaultView).toEqual('signal'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/top_n/index.tsx b/x-pack/legacy/plugins/siem/public/components/top_n/index.tsx new file mode 100644 index 0000000000000..983b234a04eaa --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/top_n/index.tsx @@ -0,0 +1,166 @@ +/* + * 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 { connect, ConnectedProps } from 'react-redux'; + +import { GlobalTime } from '../../containers/global_time'; +import { BrowserFields, WithSource } from '../../containers/source'; +import { useKibana } from '../../lib/kibana'; +import { esQuery, Filter, Query } from '../../../../../../../src/plugins/data/public'; +import { inputsModel, inputsSelectors, State, timelineSelectors } from '../../store'; +import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions'; +import { timelineDefaults } from '../../store/timeline/defaults'; +import { TimelineModel } from '../../store/timeline/model'; +import { combineQueries } from '../timeline/helpers'; +import { useTimelineTypeContext } from '../timeline/timeline_context'; + +import { getOptions } from './helpers'; +import { TopN } from './top_n'; + +/** The currently active timeline always has this Redux ID */ +export const ACTIVE_TIMELINE_REDUX_ID = 'timeline-1'; + +const EMPTY_FILTERS: Filter[] = []; +const EMPTY_QUERY: Query = { query: '', language: 'kuery' }; + +const makeMapStateToProps = () => { + const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); + const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + const getInputsTimeline = inputsSelectors.getTimelineSelector(); + const getKqlQueryTimeline = timelineSelectors.getKqlFilterQuerySelector(); + + // The mapped Redux state provided to this component includes the global + // filters that appear at the top of most views in the app, and all the + // filters in the active timeline: + const mapStateToProps = (state: State) => { + const activeTimeline: TimelineModel = + getTimeline(state, ACTIVE_TIMELINE_REDUX_ID) ?? timelineDefaults; + const activeTimelineFilters = activeTimeline.filters ?? EMPTY_FILTERS; + const activeTimelineInput: inputsModel.InputsRange = getInputsTimeline(state); + + return { + activeTimelineEventType: activeTimeline.eventType, + activeTimelineFilters, + activeTimelineFrom: activeTimelineInput.timerange.from, + activeTimelineKqlQueryExpression: getKqlQueryTimeline(state, ACTIVE_TIMELINE_REDUX_ID), + activeTimelineTo: activeTimelineInput.timerange.to, + dataProviders: activeTimeline.dataProviders, + globalQuery: getGlobalQuerySelector(state), + globalFilters: getGlobalFiltersQuerySelector(state), + kqlMode: activeTimeline.kqlMode, + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = { setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker }; + +const connector = connect(makeMapStateToProps, mapDispatchToProps); + +interface OwnProps { + browserFields: BrowserFields; + field: string; + toggleTopN: () => void; + onFilterAdded?: () => void; + value?: string[] | string | null; +} +type PropsFromRedux = ConnectedProps; +type Props = OwnProps & PropsFromRedux; + +const StatefulTopNComponent: React.FC = ({ + activeTimelineEventType, + activeTimelineFilters, + activeTimelineFrom, + activeTimelineKqlQueryExpression, + activeTimelineTo, + browserFields, + dataProviders, + field, + globalFilters = EMPTY_FILTERS, + globalQuery = EMPTY_QUERY, + kqlMode, + onFilterAdded, + setAbsoluteRangeDatePicker, + toggleTopN, + value, +}) => { + const kibana = useKibana(); + + // Regarding data from useTimelineTypeContext: + // * `documentType` (e.g. 'signals') may only be populated in some views, + // e.g. the `Signals` view on the `Detections` page. + // * `id` (`timelineId`) may only be populated when we are rendered in the + // context of the active timeline. + // * `indexToAdd`, which enables the signals index to be appended to + // the `indexPattern` returned by `WithSource`, may only be populated when + // this component is rendered in the context of the active timeline. This + // behavior enables the 'All events' view by appending the signals index + // to the index pattern. + const { documentType, id: timelineId, indexToAdd } = useTimelineTypeContext(); + + const options = getOptions( + timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineEventType : undefined + ); + + return ( + + {({ from, deleteQuery, setQuery, to }) => ( + + {({ indexPattern }) => ( + + )} + + )} + + ); +}; + +StatefulTopNComponent.displayName = 'StatefulTopNComponent'; + +export const StatefulTopN = connector(React.memo(StatefulTopNComponent)); diff --git a/x-pack/legacy/plugins/siem/public/components/top_n/top_n.test.tsx b/x-pack/legacy/plugins/siem/public/components/top_n/top_n.test.tsx new file mode 100644 index 0000000000000..13b77ea0ccd4c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/top_n/top_n.test.tsx @@ -0,0 +1,261 @@ +/* + * 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 { mount, ReactWrapper } from 'enzyme'; +import React from 'react'; + +import { TestProviders, mockIndexPattern } from '../../mock'; +import { setAbsoluteRangeDatePicker } from '../../store/inputs/actions'; + +import { allEvents, defaultOptions } from './helpers'; +import { TopN } from './top_n'; + +jest.mock('../../lib/kibana'); + +jest.mock('uuid', () => { + return { + v1: jest.fn(() => 'uuid.v1()'), + v4: jest.fn(() => 'uuid.v4()'), + }; +}); + +const field = 'process.name'; +const value = 'nice'; +const combinedQueries = { + bool: { + must: [], + filter: [ + { + bool: { + filter: [ + { + bool: { + filter: [ + { + bool: { + should: [{ match_phrase: { 'network.transport': 'tcp' } }], + minimum_should_match: 1, + }, + }, + { + bool: { should: [{ exists: { field: 'host.name' } }], minimum_should_match: 1 }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + bool: { + should: [{ range: { '@timestamp': { gte: 1586824450493 } } }], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [{ range: { '@timestamp': { lte: 1586910850493 } } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + ], + }, + }, + { match_phrase: { 'source.port': { query: '30045' } } }, + ], + should: [], + must_not: [], + }, +}; + +describe('TopN', () => { + // Suppress warnings about "react-beautiful-dnd" + /* eslint-disable no-console */ + const originalError = console.error; + const originalWarn = console.warn; + beforeAll(() => { + console.warn = jest.fn(); + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + console.warn = originalWarn; + }); + + const query = { query: '', language: 'kuery' }; + + describe('common functionality', () => { + let toggleTopN: () => void; + let wrapper: ReactWrapper; + + beforeEach(() => { + toggleTopN = jest.fn(); + wrapper = mount( + + + + ); + }); + + test('it invokes the toggleTopN function when the close button is clicked', () => { + wrapper + .find('[data-test-subj="close"]') + .first() + .simulate('click'); + wrapper.update(); + + expect(toggleTopN).toHaveBeenCalled(); + }); + + test('it enables the view select by default', () => { + expect( + wrapper + .find('[data-test-subj="view-select"]') + .first() + .props().disabled + ).toBe(false); + }); + }); + + describe('events view', () => { + let toggleTopN: () => void; + let wrapper: ReactWrapper; + + beforeEach(() => { + toggleTopN = jest.fn(); + wrapper = mount( + + + + ); + }); + + test(`it renders EventsByDataset when defaultView is 'raw'`, () => { + expect( + wrapper.find('[data-test-subj="eventsByDatasetOverview-uuid.v4()Panel"]').exists() + ).toBe(true); + }); + + test(`it does NOT render SignalsByCategory when defaultView is 'raw'`, () => { + expect(wrapper.find('[data-test-subj="signals-histogram-panel"]').exists()).toBe(false); + }); + }); + + describe('signals view', () => { + let toggleTopN: () => void; + let wrapper: ReactWrapper; + + beforeEach(() => { + toggleTopN = jest.fn(); + wrapper = mount( + + + + ); + }); + + test(`it renders SignalsByCategory when defaultView is 'signal'`, () => { + expect(wrapper.find('[data-test-subj="signals-histogram-panel"]').exists()).toBe(true); + }); + + test(`it does NOT render EventsByDataset when defaultView is 'signal'`, () => { + expect( + wrapper.find('[data-test-subj="eventsByDatasetOverview-uuid.v4()Panel"]').exists() + ).toBe(false); + }); + }); + + describe('All events, a view shown only when rendered in the context of the active timeline', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + wrapper = mount( + + + + ); + }); + + test(`it disables the view select when 'options' contains only one entry`, () => { + expect( + wrapper + .find('[data-test-subj="view-select"]') + .first() + .props().disabled + ).toBe(true); + }); + + test(`it renders EventsByDataset when defaultView is 'all'`, () => { + expect( + wrapper.find('[data-test-subj="eventsByDatasetOverview-uuid.v4()Panel"]').exists() + ).toBe(true); + }); + + test(`it does NOT render SignalsByCategory when defaultView is 'all'`, () => { + expect(wrapper.find('[data-test-subj="signals-histogram-panel"]').exists()).toBe(false); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/top_n/top_n.tsx b/x-pack/legacy/plugins/siem/public/components/top_n/top_n.tsx new file mode 100644 index 0000000000000..136252617e2a2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/top_n/top_n.tsx @@ -0,0 +1,158 @@ +/* + * 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 { EuiButtonIcon, EuiSuperSelect } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { ActionCreator } from 'typescript-fsa'; + +import { EventsByDataset } from '../../pages/overview/events_by_dataset'; +import { SignalsByCategory } from '../../pages/overview/signals_by_category'; +import { Filter, IIndexPattern, Query } from '../../../../../../../src/plugins/data/public'; +import { inputsModel } from '../../store'; +import { InputsModelId } from '../../store/inputs/constants'; +import { EventType } from '../../store/timeline/model'; + +import { TopNOption } from './helpers'; +import * as i18n from './translations'; + +const TopNContainer = styled.div` + width: 600px; +`; + +const CloseButton = styled(EuiButtonIcon)` + z-index: 999999; + position: absolute; + right: 4px; + top: 4px; +`; + +const ViewSelect = styled(EuiSuperSelect)` + z-index: 999999; + width: 155px; +`; + +const TopNContent = styled.div` + margin-top: 4px; + + .euiPanel { + border: none; + } +`; + +export interface Props { + combinedQueries?: string; + defaultView: EventType; + deleteQuery?: ({ id }: { id: string }) => void; + field: string; + filters: Filter[]; + from: number; + indexPattern: IIndexPattern; + indexToAdd?: string[] | null; + options: TopNOption[]; + query: Query; + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: number; + to: number; + }>; + setAbsoluteRangeDatePickerTarget: InputsModelId; + setQuery: (params: { + id: string; + inspect: inputsModel.InspectQuery | null; + loading: boolean; + refetch: inputsModel.Refetch; + }) => void; + to: number; + toggleTopN: () => void; + onFilterAdded?: () => void; + value?: string[] | string | null; +} + +const NO_FILTERS: Filter[] = []; +const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; + +const TopNComponent: React.FC = ({ + combinedQueries, + defaultView, + deleteQuery, + filters = NO_FILTERS, + field, + from, + indexPattern, + indexToAdd, + options, + query = DEFAULT_QUERY, + setAbsoluteRangeDatePicker, + setAbsoluteRangeDatePickerTarget, + setQuery, + to, + toggleTopN, +}) => { + const [view, setView] = useState(defaultView); + const onViewSelected = useCallback((value: string) => setView(value as EventType), [setView]); + + const headerChildren = useMemo( + () => ( + + ), + [onViewSelected, options, view] + ); + + return ( + + + + + {view === 'raw' || view === 'all' ? ( + + ) : ( + + )} + + + ); +}; + +TopNComponent.displayName = 'TopNComponent'; + +export const TopN = React.memo(TopNComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/top_n/translations.ts b/x-pack/legacy/plugins/siem/public/components/top_n/translations.ts new file mode 100644 index 0000000000000..7db55fa94d42e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/top_n/translations.ts @@ -0,0 +1,23 @@ +/* + * 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 CLOSE = i18n.translate('xpack.siem.topN.closeButtonLabel', { + defaultMessage: 'Close', +}); + +export const ALL_EVENTS = i18n.translate('xpack.siem.topN.allEventsSelectLabel', { + defaultMessage: 'All events', +}); + +export const RAW_EVENTS = i18n.translate('xpack.siem.topN.rawEventsSelectLabel', { + defaultMessage: 'Raw events', +}); + +export const SIGNAL_EVENTS = i18n.translate('xpack.siem.topN.signalEventsSelectLabel', { + defaultMessage: 'Signal events', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx b/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx index 07ea165fcbb5c..86a9acc486b6d 100644 --- a/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useCallback } from 'react'; -import styled from 'styled-components'; +import { EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { IS_DRAGGING_CLASS_NAME } from '../drag_and_drop/helpers'; interface Props { /** @@ -26,34 +27,6 @@ interface Props { */ render: (showHoverContent: boolean) => JSX.Element; } - -const HoverActionsPanelContainer = styled.div` - color: ${({ theme }) => theme.eui.textColors.default}; - height: 100%; - position: relative; -`; - -HoverActionsPanelContainer.displayName = 'HoverActionsPanelContainer'; - -const HoverActionsPanel = React.memo<{ children: JSX.Element; show: boolean }>( - ({ children, show }) => ( - - {show ? children : null} - - ) -); - -HoverActionsPanel.displayName = 'HoverActionsPanel'; - -const WithHoverActionsContainer = styled.div` - display: flex; - flex-direction: row; - height: 100%; - padding-right: 5px; -`; - -WithHoverActionsContainer.displayName = 'WithHoverActionsContainer'; - /** * Decorates it's children with actions that are visible on hover. * This component does not enforce an opinion on the styling and @@ -68,20 +41,41 @@ export const WithHoverActions = React.memo( ({ alwaysShow = false, hoverContent, render }) => { const [showHoverContent, setShowHoverContent] = useState(false); const onMouseEnter = useCallback(() => { - setShowHoverContent(true); + // NOTE: the following read from the DOM is expensive, but not as + // expensive as the default behavior, which adds a div to the body, + // which-in turn performs a more expensive change to the layout + if (!document.body.classList.contains(IS_DRAGGING_CLASS_NAME)) { + setShowHoverContent(true); + } }, []); const onMouseLeave = useCallback(() => { setShowHoverContent(false); }, []); + const content = useMemo(() => <>{render(showHoverContent)}, [render, showHoverContent]); + + const isOpen = hoverContent != null && (showHoverContent || alwaysShow); + + const popover = useMemo(() => { + return ( + + {isOpen ? hoverContent : null} + + ); + }, [content, onMouseLeave, isOpen, alwaysShow, hoverContent]); + return ( - - <>{render(showHoverContent)} - - {hoverContent != null ? hoverContent : <>} - - +
+ {popover} +
); } ); diff --git a/x-pack/legacy/plugins/siem/public/containers/matrix_histogram/index.ts b/x-pack/legacy/plugins/siem/public/containers/matrix_histogram/index.ts index 0b369b4180fb8..83b3a8fdbb68c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/matrix_histogram/index.ts +++ b/x-pack/legacy/plugins/siem/public/containers/matrix_histogram/index.ts @@ -3,7 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useState, useRef } from 'react'; +import { isEmpty } from 'lodash/fp'; +import { useEffect, useMemo, useState, useRef } from 'react'; + import { MatrixHistogramQueryProps } from '../../components/matrix_histogram/types'; import { DEFAULT_INDEX_KEY } from '../../../common/constants'; import { errorToToaster, useStateToaster } from '../../components/toasters'; @@ -19,11 +21,19 @@ export const useQuery = ({ errorMessage, filterQuery, histogramType, + indexToAdd, isInspected, stackByField, startDate, }: MatrixHistogramQueryProps) => { - const [defaultIndex] = useUiSetting$(DEFAULT_INDEX_KEY); + const [configIndex] = useUiSetting$(DEFAULT_INDEX_KEY); + const defaultIndex = useMemo(() => { + if (indexToAdd != null && !isEmpty(indexToAdd)) { + return [...configIndex, ...indexToAdd]; + } + return configIndex; + }, [configIndex, indexToAdd]); + const [, dispatchToaster] = useStateToaster(); const refetch = useRef(); const [loading, setLoading] = useState(false); @@ -96,6 +106,7 @@ export const useQuery = ({ errorMessage, filterQuery, histogramType, + indexToAdd, isInspected, stackByField, startDate, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/helpers.tsx index 27ee552146092..5c89a7e25b7a4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/helpers.tsx @@ -5,6 +5,7 @@ */ import { HistogramData, SignalsAggregation, SignalsBucket, SignalsGroupBucket } from './types'; +import { showAllOthersBucket } from '../../../../../common/constants'; import { SignalSearchResponse } from '../../../../containers/detection_engine/signals/types'; import * as i18n from './translations'; @@ -34,48 +35,56 @@ export const getSignalsHistogramQuery = ( additionalFilters: Array<{ bool: { filter: unknown[]; should: unknown[]; must_not: unknown[]; must: unknown[] }; }> -) => ({ - aggs: { - signalsByGrouping: { - terms: { - field: stackByField, +) => { + const missing = showAllOthersBucket.includes(stackByField) + ? { missing: stackByField.endsWith('.ip') ? '0.0.0.0' : i18n.ALL_OTHERS, - order: { - _count: 'desc', + } + : {}; + + return { + aggs: { + signalsByGrouping: { + terms: { + field: stackByField, + ...missing, + order: { + _count: 'desc', + }, + size: 10, }, - size: 10, - }, - aggs: { - signals: { - date_histogram: { - field: '@timestamp', - fixed_interval: `${Math.floor((to - from) / 32)}ms`, - min_doc_count: 0, - extended_bounds: { - min: from, - max: to, + aggs: { + signals: { + date_histogram: { + field: '@timestamp', + fixed_interval: `${Math.floor((to - from) / 32)}ms`, + min_doc_count: 0, + extended_bounds: { + min: from, + max: to, + }, }, }, }, }, }, - }, - query: { - bool: { - filter: [ - ...additionalFilters, - { - range: { - '@timestamp': { - gte: from, - lte: to, + query: { + bool: { + filter: [ + ...additionalFilters, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, }, }, - }, - ], + ], + }, }, - }, -}); + }; +}; /** * Returns `true` when the signals histogram initial loading spinner should be shown diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx index e25442b31da4e..f2d722e5a66d7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx @@ -9,16 +9,20 @@ import numeral from '@elastic/numeral'; import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; +import uuid from 'uuid'; +import { LegendItem } from '../../../../components/charts/draggable_legend_item'; +import { escapeDataProviderId } from '../../../../components/drag_and_drop/helpers'; import { HeaderSection } from '../../../../components/header_section'; - import { Filter, esQuery, Query } from '../../../../../../../../../src/plugins/data/public'; import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants'; import { useQuerySignals } from '../../../../containers/detection_engine/signals/use_query'; import { getDetectionEngineUrl } from '../../../../components/link_to'; +import { defaultLegendColors } from '../../../../components/matrix_histogram/utils'; import { InspectButtonContainer } from '../../../../components/inspect'; import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { MatrixLoader } from '../../../../components/matrix_histogram/matrix_loader'; +import { MatrixHistogramOption } from '../../../../components/matrix_histogram/types'; import { useKibana, useUiSetting$ } from '../../../../lib/kibana'; import { navTabs } from '../../../home/home_navigations'; import { signalsHistogramOptions } from './config'; @@ -53,6 +57,9 @@ interface SignalsHistogramPanelProps { deleteQuery?: ({ id }: { id: string }) => void; filters?: Filter[]; from: number; + headerChildren?: React.ReactNode; + /** Override all defaults, and only display this field */ + onlyField?: string; query?: Query; legendPosition?: Position; panelHeight?: number; @@ -66,12 +73,21 @@ interface SignalsHistogramPanelProps { updateDateRange: (min: number, max: number) => void; } +const getHistogramOption = (fieldName: string): MatrixHistogramOption => ({ + text: fieldName, + value: fieldName, +}); + +const NO_LEGEND_DATA: LegendItem[] = []; + export const SignalsHistogramPanel = memo( ({ chartHeight, defaultStackByOption = signalsHistogramOptions[0], deleteQuery, filters, + headerChildren, + onlyField, query, from, legendPosition = 'right', @@ -85,11 +101,13 @@ export const SignalsHistogramPanel = memo( title = i18n.HISTOGRAM_HEADER, updateDateRange, }) => { + // create a unique, but stable (across re-renders) query id + const uniqueQueryId = useMemo(() => `${DETECTIONS_HISTOGRAM_ID}-${uuid.v4()}`, []); const [isInitialLoading, setIsInitialLoading] = useState(true); const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); const [totalSignalsObj, setTotalSignalsObj] = useState(defaultTotalSignalsObj); const [selectedStackByOption, setSelectedStackByOption] = useState( - defaultStackByOption + onlyField == null ? defaultStackByOption : getHistogramOption(onlyField) ); const { loading: isLoadingSignals, @@ -123,6 +141,21 @@ export const SignalsHistogramPanel = memo( const formattedSignalsData = useMemo(() => formatSignalsData(signalsData), [signalsData]); + const legendItems: LegendItem[] = useMemo( + () => + signalsData?.aggregations?.signalsByGrouping?.buckets != null + ? signalsData.aggregations.signalsByGrouping.buckets.map((bucket, i) => ({ + color: i < defaultLegendColors.length ? defaultLegendColors[i] : undefined, + dataProviderId: escapeDataProviderId( + `draggable-legend-item-${uuid.v4()}-${selectedStackByOption.value}-${bucket.key}` + ), + field: selectedStackByOption.value, + value: bucket.key, + })) + : NO_LEGEND_DATA, + [signalsData, selectedStackByOption.value] + ); + useEffect(() => { let canceled = false; @@ -138,7 +171,7 @@ export const SignalsHistogramPanel = memo( useEffect(() => { return () => { if (deleteQuery) { - deleteQuery({ id: DETECTIONS_HISTOGRAM_ID }); + deleteQuery({ id: uniqueQueryId }); } }; }, []); @@ -146,7 +179,7 @@ export const SignalsHistogramPanel = memo( useEffect(() => { if (refetch != null && setQuery != null) { setQuery({ - id: DETECTIONS_HISTOGRAM_ID, + id: uniqueQueryId, inspect: { dsl: [request], response: [response], @@ -197,46 +230,49 @@ export const SignalsHistogramPanel = memo( } }, [showLinkToSignals, urlSearch]); + const titleText = useMemo(() => (onlyField == null ? title : i18n.TOP(onlyField)), [ + onlyField, + title, + ]); + return ( - + + + + + {stackByOptions && ( + + )} + {headerChildren != null && headerChildren} + + {linkButton} + + + {isInitialLoading ? ( - <> - - - + ) : ( - <> - - - - {stackByOptions && ( - - )} - - {linkButton} - - - - - + )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.test.tsx index 5eb9beaaaf76a..6a116efb8f2f8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.test.tsx @@ -14,7 +14,14 @@ jest.mock('../../../../lib/kibana'); describe('SignalsHistogram', () => { it('renders correctly', () => { const wrapper = shallow( - + ); expect(wrapper.find('Chart')).toBeTruthy(); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.tsx index 40e5b8abde072..4bb7e9f6e122f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram.tsx @@ -12,11 +12,14 @@ import { Settings, ChartSizeArray, } from '@elastic/charts'; +import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; import React, { useMemo } from 'react'; -import { EuiProgress } from '@elastic/eui'; import { useTheme } from '../../../../components/charts/common'; import { histogramDateTimeFormatter } from '../../../../components/utils'; +import { DraggableLegend } from '../../../../components/charts/draggable_legend'; +import { LegendItem } from '../../../../components/charts/draggable_legend_item'; + import { HistogramData } from './types'; const DEFAULT_CHART_HEIGHT = 174; @@ -24,18 +27,19 @@ const DEFAULT_CHART_HEIGHT = 174; interface HistogramSignalsProps { chartHeight?: number; from: number; + legendItems: LegendItem[]; legendPosition?: Position; loading: boolean; to: number; data: HistogramData[]; updateDateRange: (min: number, max: number) => void; } - export const SignalsHistogram = React.memo( ({ chartHeight = DEFAULT_CHART_HEIGHT, data, from, + legendItems, legendPosition = 'right', loading, to, @@ -62,29 +66,38 @@ export const SignalsHistogram = React.memo( /> )} - - + + + + - + - + - - + + +
+ + {legendItems.length > 0 && ( + + )} + + ); } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts index 8c88fa4a5dae6..e7b76a48c7592 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts @@ -83,6 +83,12 @@ export const STACK_BY_USERS = i18n.translate( } ); +export const TOP = (fieldName: string) => + i18n.translate('xpack.siem.detectionEngine.signals.histogram.topNLabel', { + values: { fieldName }, + defaultMessage: `Top {fieldName}`, + }); + export const HISTOGRAM_HEADER = i18n.translate( 'xpack.siem.detectionEngine.signals.histogram.headerTitle', { diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/alerts_by_category/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/alerts_by_category/index.test.tsx index d838b936a2d65..bd9743bdccb4b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/alerts_by_category/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/alerts_by_category/index.test.tsx @@ -66,8 +66,8 @@ describe('Alerts by category', () => { ); }); - test('it does NOT render the subtitle', () => { - expect(wrapper.find('[data-test-subj="header-panel-subtitle"]').exists()).toBe(false); + test('it renders the subtitle (to prevent layout thrashing)', () => { + expect(wrapper.find('[data-test-subj="header-panel-subtitle"]').exists()).toBe(true); }); test('it renders the expected filter fields', () => { diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/events_by_dataset/__mocks__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/events_by_dataset/__mocks__/index.tsx new file mode 100644 index 0000000000000..dad1e0034b4e2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/overview/events_by_dataset/__mocks__/index.tsx @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export const EventsByDataset = () => 'mock EventsByDataset'; diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/events_by_dataset/index.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/events_by_dataset/index.tsx index cc1f9b1cc5681..485fec31db240 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/events_by_dataset/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/events_by_dataset/index.tsx @@ -4,18 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Position } from '@elastic/charts'; import { EuiButton } from '@elastic/eui'; import numeral from '@elastic/numeral'; import React, { useEffect, useMemo } from 'react'; +import uuid from 'uuid'; -import { Position } from '@elastic/charts'; +import { DEFAULT_NUMBER_FORMAT } from '../../../../common/constants'; import { SHOWING, UNIT } from '../../../components/events_viewer/translations'; -import { convertToBuildEsQuery } from '../../../lib/keury'; import { getTabsOnHostsUrl } from '../../../components/link_to/redirect_to_hosts'; -import { histogramConfigs } from '../../../pages/hosts/navigation/events_query_tab_body'; import { MatrixHistogramContainer } from '../../../components/matrix_histogram'; +import { + MatrixHisrogramConfigs, + MatrixHistogramOption, +} from '../../../components/matrix_histogram/types'; +import { useGetUrlSearch } from '../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../home/home_navigations'; import { eventsStackByOptions } from '../../hosts/navigation'; +import { convertToBuildEsQuery } from '../../../lib/keury'; import { useKibana, useUiSetting$ } from '../../../lib/kibana'; +import { histogramConfigs } from '../../../pages/hosts/navigation/events_query_tab_body'; import { Filter, esQuery, @@ -24,12 +32,9 @@ import { } from '../../../../../../../../src/plugins/data/public'; import { inputsModel } from '../../../store'; import { HostsTableType, HostsType } from '../../../store/hosts/model'; -import { DEFAULT_NUMBER_FORMAT } from '../../../../common/constants'; +import { InputsModelId } from '../../../store/inputs/constants'; import * as i18n from '../translations'; -import { MatrixHisrogramConfigs } from '../../../components/matrix_histogram/types'; -import { useGetUrlSearch } from '../../../components/navigation/use_get_url_search'; -import { navTabs } from '../../home/home_navigations'; const NO_FILTERS: Filter[] = []; const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; @@ -38,36 +43,56 @@ const DEFAULT_STACK_BY = 'event.dataset'; const ID = 'eventsByDatasetOverview'; interface Props { + combinedQueries?: string; deleteQuery?: ({ id }: { id: string }) => void; filters?: Filter[]; from: number; + headerChildren?: React.ReactNode; indexPattern: IIndexPattern; + indexToAdd?: string[] | null; + onlyField?: string; query?: Query; + setAbsoluteRangeDatePickerTarget?: InputsModelId; setQuery: (params: { id: string; inspect: inputsModel.InspectQuery | null; loading: boolean; refetch: inputsModel.Refetch; }) => void; + showSpacer?: boolean; to: number; } +const getHistogramOption = (fieldName: string): MatrixHistogramOption => ({ + text: fieldName, + value: fieldName, +}); + const EventsByDatasetComponent: React.FC = ({ + combinedQueries, deleteQuery, filters = NO_FILTERS, from, + headerChildren, indexPattern, + indexToAdd, + onlyField, query = DEFAULT_QUERY, + setAbsoluteRangeDatePickerTarget, setQuery, + showSpacer = true, to, }) => { + // create a unique, but stable (across re-renders) query id + const uniqueQueryId = useMemo(() => `${ID}-${uuid.v4()}`, []); + useEffect(() => { return () => { if (deleteQuery) { - deleteQuery({ id: ID }); + deleteQuery({ id: uniqueQueryId }); } }; - }, [deleteQuery]); + }, [deleteQuery, uniqueQueryId]); const kibana = useKibana(); const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); @@ -84,38 +109,62 @@ const EventsByDatasetComponent: React.FC = ({ const filterQuery = useMemo( () => - convertToBuildEsQuery({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - indexPattern, - queries: [query], - filters, - }), - [kibana, indexPattern, query, filters] + combinedQueries == null + ? convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), + indexPattern, + queries: [query], + filters, + }) + : combinedQueries, + [combinedQueries, kibana, indexPattern, query, filters] ); const eventsByDatasetHistogramConfigs: MatrixHisrogramConfigs = useMemo( () => ({ ...histogramConfigs, + stackByOptions: + onlyField != null ? [getHistogramOption(onlyField)] : histogramConfigs.stackByOptions, defaultStackByOption: - eventsStackByOptions.find(o => o.text === DEFAULT_STACK_BY) ?? eventsStackByOptions[0], + onlyField != null + ? getHistogramOption(onlyField) + : eventsStackByOptions.find(o => o.text === DEFAULT_STACK_BY) ?? eventsStackByOptions[0], legendPosition: Position.Right, subtitle: (totalCount: number) => `${SHOWING}: ${numeral(totalCount).format(defaultNumberFormat)} ${UNIT(totalCount)}`, + titleSize: onlyField == null ? 'm' : 's', }), - [] + [onlyField, defaultNumberFormat] ); + const headerContent = useMemo(() => { + if (onlyField == null || headerChildren != null) { + return ( + <> + {headerChildren} + {onlyField == null && eventsCountViewEventsButton} + + ); + } else { + return null; + } + }, [onlyField, headerChildren, eventsCountViewEventsButton]); + return ( ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/overview.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/overview.tsx index 2db49e60193fc..82f4444728902 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/overview.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/overview.tsx @@ -63,7 +63,7 @@ const OverviewComponent: React.FC = ({ from={from} indexPattern={indexPattern} query={query} - setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker!} + setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} setQuery={setQuery} to={to} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/signals_by_category/index.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/signals_by_category/index.tsx index 5f78c4c10eb37..feba80539a11b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/signals_by_category/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/signals_by_category/index.tsx @@ -12,6 +12,7 @@ import { useSignalIndex } from '../../../containers/detection_engine/signals/use import { SetAbsoluteRangeDatePicker } from '../../network/types'; import { Filter, IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; import { inputsModel } from '../../../store'; +import { InputsModelId } from '../../../store/inputs/constants'; import * as i18n from '../translations'; const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; @@ -22,9 +23,13 @@ interface Props { deleteQuery?: ({ id }: { id: string }) => void; filters?: Filter[]; from: number; + headerChildren?: React.ReactNode; indexPattern: IIndexPattern; + /** Override all defaults, and only display this field */ + onlyField?: string; query?: Query; setAbsoluteRangeDatePicker: SetAbsoluteRangeDatePicker; + setAbsoluteRangeDatePickerTarget?: InputsModelId; setQuery: (params: { id: string; inspect: inputsModel.InspectQuery | null; @@ -38,15 +43,18 @@ const SignalsByCategoryComponent: React.FC = ({ deleteQuery, filters = NO_FILTERS, from, + headerChildren, + onlyField, query = DEFAULT_QUERY, setAbsoluteRangeDatePicker, + setAbsoluteRangeDatePickerTarget = 'global', setQuery, to, }) => { const { signalIndexName } = useSignalIndex(); const updateDateRangeCallback = useCallback( (min: number, max: number) => { - setAbsoluteRangeDatePicker!({ id: 'global', from: min, to: max }); + setAbsoluteRangeDatePicker({ id: setAbsoluteRangeDatePickerTarget, from: min, to: max }); }, [setAbsoluteRangeDatePicker] ); @@ -60,12 +68,14 @@ const SignalsByCategoryComponent: React.FC = ({ defaultStackByOption={defaultStackByOption} filters={filters} from={from} + headerChildren={headerChildren} + onlyField={onlyField} query={query} signalIndexName={signalIndexName} setQuery={setQuery} showTotalSignalsCount={true} - showLinkToSignals={true} - stackByOptions={signalsHistogramOptions} + showLinkToSignals={onlyField == null ? true : false} + stackByOptions={onlyField == null ? signalsHistogramOptions : undefined} legendPosition={'right'} to={to} title={i18n.SIGNAL_COUNT} diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts b/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts index 601a629d86e57..b7bee15e4c5bf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts @@ -38,6 +38,12 @@ export const SIGNAL_COUNT = i18n.translate('xpack.siem.overview.signalCountTitle defaultMessage: 'Signal count', }); +export const TOP = (fieldName: string) => + i18n.translate('xpack.siem.overview.topNLabel', { + values: { fieldName }, + defaultMessage: `Top {fieldName}`, + }); + export const VIEW_ALERTS = i18n.translate('xpack.siem.overview.viewAlertsButtonLabel', { defaultMessage: 'View alerts', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.test.tsx b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.test.tsx index 62399891c9606..ae95a1316a600 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.test.tsx @@ -10,6 +10,8 @@ import { shallow, ShallowWrapper } from 'enzyme'; import React from 'react'; import ApolloClient from 'apollo-client'; +jest.mock('../../pages/overview/events_by_dataset'); + jest.mock('../../lib/kibana', () => { return { useKibana: jest.fn(), diff --git a/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.events_over_time.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.events_over_time.dsl.ts index 3a4281b980cc4..63649a1064b02 100644 --- a/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.events_over_time.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.events_over_time.dsl.ts @@ -3,9 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +import { showAllOthersBucket } from '../../../common/constants'; import { createQueryFilterClauses, calculateTimeSeriesInterval } from '../../utils/build_query'; import { MatrixHistogramRequestOptions } from '../framework'; +import * as i18n from './translations'; + export const buildEventsOverTimeQuery = ({ filterQuery, timerange: { from, to }, @@ -41,11 +45,19 @@ export const buildEventsOverTimeQuery = ({ }, }, }; + + const missing = + stackByField != null && showAllOthersBucket.includes(stackByField) + ? { + missing: stackByField?.endsWith('.ip') ? '0.0.0.0' : i18n.ALL_OTHERS, + } + : {}; + return { eventActionGroup: { terms: { field: stackByField, - missing: 'All others', + ...missing, order: { _count: 'desc', }, diff --git a/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/translations.ts b/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/translations.ts new file mode 100644 index 0000000000000..413acaa2d4b0a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/translations.ts @@ -0,0 +1,14 @@ +/* + * 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 ALL_OTHERS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.allOthersGroupingLabel', + { + defaultMessage: 'All others', + } +);