diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 52ab4a8cb8d732..5863d525ca0f89 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -89,7 +89,7 @@ export const allowedExperimentalValues = Object.freeze({ /** * Enables top charts on Alerts Page */ - alertsPageChartsEnabled: false, + alertsPageChartsEnabled: true, /** * Keep DEPRECATED experimental flags that are documented to prevent failed upgrades. diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/helpers.tsx index ad357b81b5e22a..c0e794317205c9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/helpers.tsx @@ -7,7 +7,7 @@ import { has } from 'lodash'; import type { AlertType, AlertsByTypeAgg, AlertsTypeData } from './types'; import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; -import type { SummaryChartsData } from '../alerts_summary_charts_panel/types'; +import type { SummaryChartsData, SummaryChartsAgg } from '../alerts_summary_charts_panel/types'; export const ALERT_TYPE_COLOR = { Detection: '#D36086', @@ -62,3 +62,9 @@ const getAggregateAlerts = ( export const isAlertsTypeData = (data: SummaryChartsData[]): data is AlertsTypeData[] => { return data?.every((x) => has(x, 'type')); }; + +export const isAlertsByTypeAgg = ( + data: AlertSearchResponse<{}, SummaryChartsAgg> +): data is AlertSearchResponse<{}, AlertsByTypeAgg> => { + return has(data, 'aggregations.alertsByRule'); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx index cc2e5ca8c78d01..067b5e2e86f149 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { waitFor, act } from '@testing-library/react'; import { mount } from 'enzyme'; - import { AlertsCountPanel } from '.'; import type { Status } from '../../../../../common/detection_engine/schemas/common'; @@ -61,25 +60,31 @@ jest.mock('../common/hooks', () => ({ useInspectButton: jest.fn(), useStackByFields: jest.fn(), })); +const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; +jest.mock('../../../../common/hooks/use_experimental_features'); const defaultProps = { inspectTitle: TABLE, + signalIndexName: 'signalIndexName', + stackByField0: DEFAULT_STACK_BY_FIELD, + stackByField1: DEFAULT_STACK_BY_FIELD1, setStackByField0: jest.fn(), setStackByField1: jest.fn(), + isExpanded: true, + setIsExpanded: jest.fn(), showBuildingBlockAlerts: false, showOnlyThreatIndicatorAlerts: false, - signalIndexName: 'signalIndexName', - stackByField0: DEFAULT_STACK_BY_FIELD, - stackByField1: DEFAULT_STACK_BY_FIELD1, status: 'open' as Status, }; -const mockUseQueryToggle = useQueryToggle as jest.Mock; const mockSetToggle = jest.fn(); +const mockUseQueryToggle = useQueryToggle as jest.Mock; describe('AlertsCountPanel', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(false); // for chartEmbeddablesEnabled flag + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(false); // for alertsPageChartsEnabled flag }); it('renders correctly', async () => { @@ -177,6 +182,7 @@ describe('AlertsCountPanel', () => { }); }); }); + describe('toggleQuery', () => { it('toggles', async () => { await act(async () => { @@ -189,7 +195,7 @@ describe('AlertsCountPanel', () => { expect(mockSetToggle).toBeCalledWith(false); }); }); - it('toggleStatus=true, render', async () => { + it('alertsPageChartsEnabled is false and toggleStatus=true, render', async () => { await act(async () => { const wrapper = mount( @@ -199,7 +205,7 @@ describe('AlertsCountPanel', () => { expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(true); }); }); - it('toggleStatus=false, hide', async () => { + it('alertsPageChartsEnabled is false and toggleStatus=false, hide', async () => { mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); await act(async () => { const wrapper = mount( @@ -210,16 +216,41 @@ describe('AlertsCountPanel', () => { expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(false); }); }); + + it('alertsPageChartsEnabled is true and isExpanded=true, render', async () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(false); // for chartEmbeddablesEnabled flag + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(true); // for alertsPageChartsEnabled flag + await act(async () => { + mockUseIsExperimentalFeatureEnabled('charts', true); + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(true); + }); + }); + it('alertsPageChartsEnabled is true and isExpanded=false, hide', async () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(false); // for chartEmbeddablesEnabled flag + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(true); // for alertsPageChartsEnabled flag + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(false); + }); + }); }); }); describe('when isChartEmbeddablesEnabled = true', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); - - (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(true); // for chartEmbeddablesEnabled flag + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(false); // for alertsPageChartsEnabled flag }); it('renders LensEmbeddable', async () => { @@ -229,7 +260,7 @@ describe('when isChartEmbeddablesEnabled = true', () => { ); - expect(wrapper.find('[data-test-subj="lens-embeddable"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="embeddable-count-table"]').exists()).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 9836cf80ff196f..5a1acfbc5b96b1 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -84,6 +84,7 @@ export const AlertsCountPanel = memo( setIsExpanded, }) => { const { to, from, deleteQuery, setQuery } = useGlobalTime(false); + const isChartEmbeddablesEnabled = useIsExperimentalFeatureEnabled('chartEmbeddablesEnabled'); const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); // create a unique, but stable (across re-renders) query id const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_COUNT_ID}-${uuidv4()}`, []); @@ -130,7 +131,6 @@ export const AlertsCountPanel = memo( [setQuerySkip, setToggleStatus, setIsExpanded, isAlertsPageChartsEnabled] ); - const isChartEmbeddablesEnabled = useIsExperimentalFeatureEnabled('chartEmbeddablesEnabled'); const timerange = useMemo(() => ({ from, to }), [from, to]); const extraVisualizationOptions = useMemo( @@ -234,7 +234,7 @@ export const AlertsCountPanel = memo( (!isAlertsPageChartsEnabled && toggleStatus) ? ( { }; }); -describe('AlertsHistogramPanel', () => { - const defaultProps = { - setQuery: jest.fn(), - showBuildingBlockAlerts: false, - showOnlyThreatIndicatorAlerts: false, - signalIndexName: 'signalIndexName', - updateDateRange: jest.fn(), - }; +const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; +jest.mock('../../../../common/hooks/use_experimental_features'); - const mockSetToggle = jest.fn(); - const mockUseQueryToggle = useQueryToggle as jest.Mock; - beforeEach(() => { - mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); - }); +const defaultProps = { + setQuery: jest.fn(), + showBuildingBlockAlerts: false, + showOnlyThreatIndicatorAlerts: false, + signalIndexName: 'signalIndexName', + updateDateRange: jest.fn(), +}; +const mockSetToggle = jest.fn(); +const mockUseQueryToggle = useQueryToggle as jest.Mock; - afterEach(() => { +describe('AlertsHistogramPanel', () => { + beforeEach(() => { jest.clearAllMocks(); - jest.restoreAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); }); it('renders correctly', () => { @@ -690,26 +689,85 @@ describe('AlertsHistogramPanel', () => { expect(mockSetToggle).toBeCalledWith(false); }); }); - it('toggleStatus=true, render', async () => { - await act(async () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(MatrixLoader).exists()).toEqual(true); + describe('when alertsPageChartsEnabled = false', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(false); // for chartEmbeddablesEnabled flag + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(false); // for alertsPageChartsEnabled flag + }); + + it('toggleStatus=true, render', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(MatrixLoader).exists()).toEqual(true); + }); + }); + it('toggleStatus=false, hide', async () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find(MatrixLoader).exists()).toEqual(false); + }); }); }); - it('toggleStatus=false, hide', async () => { - mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); - await act(async () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(MatrixLoader).exists()).toEqual(false); + + describe('when alertsPageChartsEnabled = true', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(false); // for chartEmbeddablesEnabled flag + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(true); // for alertsPageChartsEnabled flag + }); + + it('isExpanded=true, render', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find(MatrixLoader).exists()).toEqual(true); + }); + }); + it('isExpanded=false, hide', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find(MatrixLoader).exists()).toEqual(false); + }); + }); + it('isExpanded is not passed in and toggleStatus =true, render', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find(MatrixLoader).exists()).toEqual(true); + }); + }); + it('isExpanded is not passed in and toggleStatus =false, hide', async () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find(MatrixLoader).exists()).toEqual(false); + }); }); }); }); @@ -717,10 +775,9 @@ describe('AlertsHistogramPanel', () => { describe('when isChartEmbeddablesEnabled = true', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); - - (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(true); // for chartEmbeddablesEnabled flag + mockUseIsExperimentalFeatureEnabled.mockReturnValueOnce(false); // for alertsPageChartsEnabled flag }); it('renders LensEmbeddable', async () => { @@ -730,7 +787,9 @@ describe('AlertsHistogramPanel', () => { ); - expect(wrapper.find('[data-test-subj="lens-embeddable"]').exists()).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="embeddable-matrix-histogram"]').exists() + ).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index 47e8dcc213f0f0..de52db990d5c79 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -163,6 +163,7 @@ export const AlertsHistogramPanel = memo( const [selectedStackByOption, setSelectedStackByOption] = useState( onlyField == null ? defaultStackByOption : onlyField ); + const isChartEmbeddablesEnabled = useIsExperimentalFeatureEnabled('chartEmbeddablesEnabled'); const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); const onSelect = useCallback( @@ -184,7 +185,7 @@ export const AlertsHistogramPanel = memo( isAlertsPageChartsEnabled ? !isExpanded : !toggleStatus ); useEffect(() => { - if (isAlertsPageChartsEnabled) { + if (isAlertsPageChartsEnabled && isExpanded !== undefined) { setQuerySkip(!isExpanded); } else { setQuerySkip(!toggleStatus); @@ -193,7 +194,7 @@ export const AlertsHistogramPanel = memo( const toggleQuery = useCallback( (newToggleStatus: boolean) => { - if (isAlertsPageChartsEnabled && setIsExpanded) { + if (isAlertsPageChartsEnabled && setIsExpanded !== undefined) { setIsExpanded(newToggleStatus); } else { setToggleStatus(newToggleStatus); @@ -204,7 +205,6 @@ export const AlertsHistogramPanel = memo( [setQuerySkip, setToggleStatus, setIsExpanded, isAlertsPageChartsEnabled] ); - const isChartEmbeddablesEnabled = useIsExperimentalFeatureEnabled('chartEmbeddablesEnabled'); const timerange = useMemo(() => ({ from, to }), [from, to]); const { @@ -364,24 +364,28 @@ export const AlertsHistogramPanel = memo( [onlyField, title] ); - const toggle = useMemo( - () => (isAlertsPageChartsEnabled && isExpanded !== undefined ? isExpanded : toggleStatus), - [isAlertsPageChartsEnabled, isExpanded, toggleStatus] - ); + const showHistogram = useMemo(() => { + if (isAlertsPageChartsEnabled) { + if (isExpanded !== undefined) { + // alerts page + return isExpanded; + } else { + // rule details page and overview page + return toggleStatus; + } + } else { + return toggleStatus; + } + }, [isAlertsPageChartsEnabled, isExpanded, toggleStatus]); - const showHistogram = useMemo( - () => - (isAlertsPageChartsEnabled && isExpanded) || (!isAlertsPageChartsEnabled && toggleStatus), - [isAlertsPageChartsEnabled, isExpanded, toggleStatus] - ); return ( - + ( outerDirection="row" title={titleText} titleSize={titleSize} - toggleStatus={toggle} + toggleStatus={showHistogram} toggleQuery={hideQueryToggle ? undefined : toggleQuery} showInspectButton={isChartEmbeddablesEnabled ? false : chartOptionsContextMenu == null} subtitle={!isInitialLoading && showTotalAlertsCount && totalAlerts} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.test.tsx index 5f8141ad57e8e9..1d972ff929e0f0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { TestProviders } from '../../../../common/mock'; import { AlertsProgressBar } from './alerts_progress_bar'; import { parsedAlerts } from './mock_data'; +import type { GroupBySelection } from './types'; jest.mock('../../../../common/lib/kibana'); @@ -21,7 +22,7 @@ describe('Alert by grouping', () => { const defaultProps = { data: [], isLoading: false, - stackByField: 'host.name', + groupBySelection: 'host.name' as GroupBySelection, }; afterEach(() => { @@ -38,7 +39,7 @@ describe('Alert by grouping', () => { ); expect( container.querySelector(`[data-test-subj="alerts-progress-bar-title"]`)?.textContent - ).toEqual(defaultProps.stackByField); + ).toEqual(defaultProps.groupBySelection); expect(container.querySelector(`[data-test-subj="empty-proress-bar"]`)).toBeInTheDocument(); expect(container.querySelector(`[data-test-subj="empty-proress-bar"]`)?.textContent).toEqual( 'No items found' @@ -50,7 +51,7 @@ describe('Alert by grouping', () => { act(() => { const { container } = render( - + ); expect( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx index e20f10543845e0..8e1c1ea65d1cd7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx @@ -7,7 +7,7 @@ import { EuiProgress, EuiSpacer, EuiText, EuiHorizontalRule } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; -import type { AlertsProgressBarData } from './types'; +import type { AlertsProgressBarData, GroupBySelection } from './types'; import { DefaultDraggable } from '../../../../common/components/draggables'; import * as i18n from './translations'; @@ -22,19 +22,19 @@ const StyledEuiText = styled(EuiText)` export interface AlertsProcessBarProps { data: AlertsProgressBarData[]; isLoading: boolean; - stackByField: string; addFilter?: ({ field, value }: { field: string; value: string | number }) => void; + groupBySelection: GroupBySelection; } export const AlertsProgressBar: React.FC = ({ data, isLoading, - stackByField, + groupBySelection, }) => { return ( <> -
{stackByField}
+
{groupBySelection}
{!isLoading && data.length === 0 ? ( @@ -64,7 +64,7 @@ export const AlertsProgressBar: React.FC = ({ ) : ( { return data?.every((x) => has(x, 'percentage')); }; + +export const isAlertsByGroupingAgg = ( + data: AlertSearchResponse<{}, SummaryChartsAgg> +): data is AlertSearchResponse<{}, AlertsByGroupingAgg> => { + return has(data, 'aggregations.alertsByGrouping'); +}; + +const labels = { + 'host.name': i18n.HOST_NAME_LABEL, + 'user.name': i18n.USER_NAME_LABEL, + 'source.ip': i18n.SOURCE_LABEL, + 'destination.ip': i18n.DESTINATION_LABEL, +}; + +export const getGroupByLabel = (option: GroupBySelection): string => { + return labels[option]; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.test.tsx index 5016e98140d324..776e4fffc89abe 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.test.tsx @@ -4,12 +4,13 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { act, render, screen } from '@testing-library/react'; +import { act, render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../../common/mock'; import { AlertsProgressBarPanel } from '.'; import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data'; import { STACK_BY_ARIA_LABEL } from '../common/translations'; +import type { GroupBySelection } from './types'; jest.mock('../../../../common/lib/kibana'); @@ -27,6 +28,8 @@ describe('Alert by grouping', () => { const defaultProps = { signalIndexName: 'signalIndexName', skip: false, + groupBySelection: 'host.name' as GroupBySelection, + setGroupBySelection: jest.fn(), }; beforeEach(() => { @@ -74,6 +77,8 @@ describe('Alert by grouping', () => { }); describe('combo box', () => { + const setGroupBySelection = jest.fn(); + test('renders combo box', async () => { await act(async () => { const { container } = render( @@ -89,7 +94,7 @@ describe('Alert by grouping', () => { await act(async () => { render( - + ); const comboBox = screen.getByRole('combobox', { name: STACK_BY_ARIA_LABEL }); @@ -102,5 +107,24 @@ describe('Alert by grouping', () => { expect(optionsFound[i]).toEqual(option); }); }); + + test('it invokes setGroupBySelection when an option is selected', async () => { + const toBeSelected = 'user.name'; + await act(async () => { + render( + + + + ); + const comboBox = screen.getByRole('combobox', { name: STACK_BY_ARIA_LABEL }); + if (comboBox) { + comboBox.focus(); // display the combo box options + } + }); + const button = await screen.findByText(toBeSelected); + fireEvent.click(button); + + expect(setGroupBySelection).toBeCalledWith(toBeSelected); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx index 0baeeb8e36d083..1e38e1cd402a52 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx @@ -7,7 +7,8 @@ import { EuiPanel, EuiLoadingSpinner } from '@elastic/eui'; import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { v4 as uuid } from 'uuid'; -import type { ChartsPanelProps } from '../alerts_summary_charts_panel/types'; +import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; +import type { Filter, Query } from '@kbn/es-query'; import { HeaderSection } from '../../../../common/components/header_section'; import { InspectButtonContainer } from '../../../../common/components/inspect'; import { StackByComboBox } from '../common/components'; @@ -17,28 +18,45 @@ import { alertsGroupingAggregations } from '../alerts_summary_charts_panel/aggre import { showInitialLoadingSpinner } from '../alerts_histogram_panel/helpers'; import { isAlertsProgressBarData } from './helpers'; import * as i18n from './translations'; +import type { GroupBySelection } from './types'; const TOP_ALERTS_CHART_ID = 'alerts-summary-top-alerts'; const DEFAULT_COMBOBOX_WIDTH = 150; const DEFAULT_OPTIONS = ['host.name', 'user.name', 'source.ip', 'destination.ip']; -export const AlertsProgressBarPanel: React.FC = ({ +interface Props { + filters?: Filter[]; + query?: Query; + signalIndexName: string | null; + runtimeMappings?: MappingRuntimeFields; + skip?: boolean; + groupBySelection: GroupBySelection; + setGroupBySelection: (groupBySelection: GroupBySelection) => void; +} +export const AlertsProgressBarPanel: React.FC = ({ filters, query, signalIndexName, runtimeMappings, skip, + groupBySelection, + setGroupBySelection, }) => { - const [stackByField, setStackByField] = useState('host.name'); const [isInitialLoading, setIsInitialLoading] = useState(true); const uniqueQueryId = useMemo(() => `${TOP_ALERTS_CHART_ID}-${uuid()}`, []); const dropDownOptions = DEFAULT_OPTIONS.map((field) => { return { value: field, label: field }; }); - const aggregations = useMemo(() => alertsGroupingAggregations(stackByField), [stackByField]); - const onSelect = useCallback((field: string) => { - setStackByField(field); - }, []); + const aggregations = useMemo( + () => alertsGroupingAggregations(groupBySelection), + [groupBySelection] + ); + const onSelect = useCallback( + (field: string) => { + setGroupBySelection(field as GroupBySelection); + }, + [setGroupBySelection] + ); const { items, isLoading } = useSummaryChartData({ aggregations, @@ -61,7 +79,7 @@ export const AlertsProgressBarPanel: React.FC = ({ = ({ > = ({ {isInitialLoading ? ( ) : ( - + )}
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/translations.ts index 514aff9fef4e1d..da6b33ad60c085 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/translations.ts @@ -26,3 +26,28 @@ export const OTHER = i18n.translate( defaultMessage: 'Other', } ); + +export const HOST_NAME_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByGrouping.hostNameLabel', + { + defaultMessage: 'host', + } +); +export const USER_NAME_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByGrouping.userNameLabel', + { + defaultMessage: 'user', + } +); +export const DESTINATION_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByGrouping.destinationLabel', + { + defaultMessage: 'destination', + } +); +export const SOURCE_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByGrouping.sourceLabel', + { + defaultMessage: 'source', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/types.ts index efc278f9b4c47f..7b5b2042a63396 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/types.ts @@ -6,6 +6,7 @@ */ import type { BucketItem } from '../../../../../common/search_strategy/security_solution/cti'; +export type GroupBySelection = 'host.name' | 'user.name' | 'source.ip' | 'destination.ip'; export interface AlertsByGroupingAgg { alertsByGrouping: { doc_count_error_upper_bound: number; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations.ts index 80151de9252bcc..e4883c022711e4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations.ts @@ -5,6 +5,7 @@ * 2.0. */ import { ALERT_SEVERITY, ALERT_RULE_NAME } from '@kbn/rule-data-utils'; +import type { GroupBySelection } from '../alerts_progress_bar_panel/types'; const DEFAULT_QUERY_SIZE = 1000; @@ -33,7 +34,7 @@ export const alertTypeAggregations = { }, }; -export const alertsGroupingAggregations = (stackByField: string) => { +export const alertsGroupingAggregations = (stackByField: GroupBySelection) => { return { alertsByGrouping: { terms: { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.tsx index 6ea064bf7b7be5..e4c7bc3f57c96a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.tsx @@ -4,12 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { isAlertsBySeverityAgg, isAlertsByTypeAgg, isAlertsByGroupingAgg } from './types'; import type { SummaryChartsAgg } from './types'; import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; -import { parseSeverityData } from '../severity_level_panel/helpers'; -import { parseAlertsTypeData } from '../alerts_by_type_panel/helpers'; -import { parseAlertsGroupingData } from '../alerts_progress_bar_panel/helpers'; +import { parseSeverityData, isAlertsBySeverityAgg } from '../severity_level_panel/helpers'; +import { parseAlertsTypeData, isAlertsByTypeAgg } from '../alerts_by_type_panel/helpers'; +import { + parseAlertsGroupingData, + isAlertsByGroupingAgg, +} from '../alerts_progress_bar_panel/helpers'; +import { + parseChartCollapseData, + isChartCollapseAgg, +} from '../../../pages/detection_engine/chart_panels/chart_collapse/helpers'; export const parseData = (data: AlertSearchResponse<{}, SummaryChartsAgg>) => { if (isAlertsBySeverityAgg(data)) { @@ -21,5 +27,8 @@ export const parseData = (data: AlertSearchResponse<{}, SummaryChartsAgg>) => { if (isAlertsByGroupingAgg(data)) { return parseAlertsGroupingData(data); } + if (isChartCollapseAgg(data)) { + return parseChartCollapseData(data); + } return []; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.test.tsx index ac5d20fcdcfa98..f02043f3d7c55c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.test.tsx @@ -9,6 +9,8 @@ import React from 'react'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { TestProviders } from '../../../../common/mock'; import { AlertsSummaryChartsPanel } from '.'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import type { GroupBySelection } from '../alerts_progress_bar_panel/types'; jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/containers/query_toggle'); @@ -18,14 +20,22 @@ jest.mock('react-router-dom', () => { return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; }); +const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; +jest.mock('../../../../common/hooks/use_experimental_features'); + describe('AlertsSummaryChartsPanel', () => { const defaultProps = { signalIndexName: 'signalIndexName', + isExpanded: true, + setIsExpanded: jest.fn(), + groupBySelection: 'host.name' as GroupBySelection, + setGroupBySelection: jest.fn(), }; const mockSetToggle = jest.fn(); const mockUseQueryToggle = useQueryToggle as jest.Mock; beforeEach(() => { mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); }); test('renders correctly', async () => { @@ -89,7 +99,7 @@ describe('AlertsSummaryChartsPanel', () => { }); }); - test('toggleStatus=true, render', async () => { + it('alertsPageChartsEnabled is false and toggleStatus=true, render', async () => { await act(async () => { const { container } = render( @@ -102,7 +112,7 @@ describe('AlertsSummaryChartsPanel', () => { }); }); - test('toggleStatus=false, hide', async () => { + it('alertsPageChartsEnabled is false and toggleStatus=false, hide', async () => { mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); await act(async () => { const { container } = render( @@ -115,5 +125,32 @@ describe('AlertsSummaryChartsPanel', () => { ).not.toBeInTheDocument(); }); }); + + it('alertsPageChartsEnabled is true and isExpanded=true, render', async () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + await act(async () => { + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="alerts-charts-container"]') + ).toBeInTheDocument(); + }); + }); + it('alertsPageChartsEnabled is true and isExpanded=false, hide', async () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + await act(async () => { + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="alerts-charts-container"]') + ).not.toBeInTheDocument(); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.tsx index 813cc19c882214..f7c8889bfc8ecf 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.tsx @@ -17,6 +17,7 @@ import { AlertsByTypePanel } from '../alerts_by_type_panel'; import { AlertsProgressBarPanel } from '../alerts_progress_bar_panel'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import type { GroupBySelection } from '../alerts_progress_bar_panel/types'; const StyledFlexGroup = styled(EuiFlexGroup)` @media only screen and (min-width: ${({ theme }) => theme.eui.euiBreakpoints.l}); @@ -39,6 +40,8 @@ interface Props { runtimeMappings?: MappingRuntimeFields; isExpanded?: boolean; setIsExpanded?: (status: boolean) => void; + groupBySelection: GroupBySelection; + setGroupBySelection: (groupBySelection: GroupBySelection) => void; } export const AlertsSummaryChartsPanel: React.FC = ({ @@ -52,6 +55,8 @@ export const AlertsSummaryChartsPanel: React.FC = ({ title = i18n.CHARTS_TITLE, isExpanded, setIsExpanded, + groupBySelection, + setGroupBySelection, }: Props) => { const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); @@ -137,12 +142,13 @@ export const AlertsSummaryChartsPanel: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/types.ts index 6e25ac136281a7..8d3821ce1db625 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/types.ts @@ -6,8 +6,6 @@ */ import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; import type { Filter, Query } from '@kbn/es-query'; -import { has } from 'lodash'; -import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types'; import type { AlertsBySeverityAgg } from '../severity_level_panel/types'; import type { AlertsByTypeAgg, AlertsTypeData } from '../alerts_by_type_panel/types'; @@ -15,10 +13,20 @@ import type { AlertsByGroupingAgg, AlertsProgressBarData, } from '../alerts_progress_bar_panel/types'; +import type { + ChartCollapseAgg, + ChartCollapseData, +} from '../../../pages/detection_engine/chart_panels/chart_collapse/types'; -export type SummaryChartsAgg = Partial; +export type SummaryChartsAgg = Partial< + AlertsBySeverityAgg | AlertsByTypeAgg | AlertsByGroupingAgg | ChartCollapseAgg +>; -export type SummaryChartsData = SeverityData | AlertsTypeData | AlertsProgressBarData; +export type SummaryChartsData = + | SeverityData + | AlertsTypeData + | AlertsProgressBarData + | ChartCollapseData; export interface ChartsPanelProps { filters?: Filter[]; @@ -28,21 +36,3 @@ export interface ChartsPanelProps { skip?: boolean; addFilter?: ({ field, value }: { field: string; value: string | number }) => void; } - -export const isAlertsBySeverityAgg = ( - data: AlertSearchResponse<{}, SummaryChartsAgg> -): data is AlertSearchResponse<{}, AlertsBySeverityAgg> => { - return has(data, 'aggregations.statusBySeverity'); -}; - -export const isAlertsByTypeAgg = ( - data: AlertSearchResponse<{}, SummaryChartsAgg> -): data is AlertSearchResponse<{}, AlertsByTypeAgg> => { - return has(data, 'aggregations.alertsByRule'); -}; - -export const isAlertsByGroupingAgg = ( - data: AlertSearchResponse<{}, SummaryChartsAgg> -): data is AlertSearchResponse<{}, AlertsByGroupingAgg> => { - return has(data, 'aggregations.alertsByGrouping'); -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/helpers.tsx index 165bbef35fcfca..2ba07124595ab5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/helpers.tsx @@ -9,7 +9,7 @@ import { has } from 'lodash'; import type { AlertsBySeverityAgg } from './types'; import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types'; -import type { SummaryChartsData } from '../alerts_summary_charts_panel/types'; +import type { SummaryChartsData, SummaryChartsAgg } from '../alerts_summary_charts_panel/types'; import { severityLabels } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; import { emptyDonutColor } from '../../../../common/components/charts/donutchart_empty'; import { SEVERITY_COLOR } from '../../../../overview/components/detection_response/utils'; @@ -38,3 +38,9 @@ export const parseSeverityData = ( export const isAlertsBySeverityData = (data: SummaryChartsData[]): data is SeverityData[] => { return data?.every((x) => has(x, 'key')); }; + +export const isAlertsBySeverityAgg = ( + data: AlertSearchResponse<{}, SummaryChartsAgg> +): data is AlertSearchResponse<{}, AlertsBySeverityAgg> => { + return has(data, 'aggregations.statusBySeverity'); +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts index 6df717c6b541ef..8dc25558f4b6f9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts @@ -34,3 +34,6 @@ export const TREND_CHART_CATEGORY = 'trend'; /** settings for view selection are grouped under this category */ export const VIEW_CATEGORY = 'view'; + +/** settings for group by selection on summary tab */ +export const GROUP_BY_SETTING_NAME = 'group-by'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.test.tsx index 6731bee771a3dd..1787955fb1b521 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.test.tsx @@ -27,6 +27,7 @@ describe('useAlertsLocalStorage', () => { alertViewSelection: 'trend', // default to the trend chart countTableStackBy0: 'kibana.alert.rule.name', countTableStackBy1: 'host.name', + groupBySelection: 'host.name', isTreemapPanelExpanded: true, riskChartStackBy0: 'kibana.alert.rule.name', riskChartStackBy1: 'host.name', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.tsx index 5ac129b6368e31..4af424c0ead8b0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.tsx @@ -22,6 +22,7 @@ import { STACK_BY_SETTING_NAME, TREND_CHART_CATEGORY, VIEW_CATEGORY, + GROUP_BY_SETTING_NAME, } from './constants'; import { DEFAULT_STACK_BY_FIELD, @@ -30,6 +31,7 @@ import { import type { AlertsSettings } from './types'; import type { AlertViewSelection } from '../chart_select/helpers'; import { TREND_ID } from '../chart_select/helpers'; +import type { GroupBySelection } from '../../../../components/alerts_kpis/alerts_progress_bar_panel/types'; export const useAlertsLocalStorage = (): AlertsSettings => { const [alertViewSelection, setAlertViewSelection] = useLocalStorage({ @@ -42,6 +44,16 @@ export const useAlertsLocalStorage = (): AlertsSettings => { isInvalidDefault: isDefaultWhenEmptyString, }); + const [groupBySelection, setGroupBySelection] = useLocalStorage({ + defaultValue: 'host.name', + key: getSettingKey({ + category: VIEW_CATEGORY, + page: ALERTS_PAGE, + setting: GROUP_BY_SETTING_NAME, + }), + isInvalidDefault: isDefaultWhenEmptyString, + }); + const [isTreemapPanelExpanded, setIsTreemapPanelExpanded] = useLocalStorage({ defaultValue: true, key: getSettingKey({ @@ -103,12 +115,14 @@ export const useAlertsLocalStorage = (): AlertsSettings => { alertViewSelection, countTableStackBy0, countTableStackBy1, + groupBySelection, isTreemapPanelExpanded, riskChartStackBy0, riskChartStackBy1, setAlertViewSelection, setCountTableStackBy0, setCountTableStackBy1, + setGroupBySelection, setIsTreemapPanelExpanded, setRiskChartStackBy0, setRiskChartStackBy1, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/types.ts index e909fc66f11db3..88a73579c764fa 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/types.ts @@ -6,17 +6,19 @@ */ import type { AlertViewSelection } from '../chart_select/helpers'; - +import type { GroupBySelection } from '../../../../components/alerts_kpis/alerts_progress_bar_panel/types'; export interface AlertsSettings { alertViewSelection: AlertViewSelection; countTableStackBy0: string; countTableStackBy1: string | undefined; + groupBySelection: GroupBySelection; isTreemapPanelExpanded: boolean; riskChartStackBy0: string; riskChartStackBy1: string | undefined; setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; setCountTableStackBy0: (value: string) => void; setCountTableStackBy1: (value: string | undefined) => void; + setGroupBySelection: (groupBySelection: GroupBySelection) => void; setIsTreemapPanelExpanded: (value: boolean) => void; setRiskChartStackBy0: (value: string) => void; setRiskChartStackBy1: (value: string | undefined) => void; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/helpers.test.tsx new file mode 100644 index 00000000000000..ff03ddb279b722 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/helpers.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { parseChartCollapseData } from './helpers'; +import * as mock from './mock_data'; +import type { ChartCollapseAgg } from './types'; +import type { AlertSearchResponse } from '../../../../containers/detection_engine/alerts/types'; +import { getGroupByLabel } from '../../../../components/alerts_kpis/alerts_progress_bar_panel/helpers'; +import * as i18n from '../../../../components/alerts_kpis/alerts_progress_bar_panel/translations'; + +describe('parse chart collapse data', () => { + test('parse alerts with data', () => { + const res = parseChartCollapseData( + mock.mockAlertsData as AlertSearchResponse<{}, ChartCollapseAgg> + ); + expect(res).toEqual(mock.parsedAlerts); + }); + + test('parse alerts without data', () => { + const res = parseChartCollapseData( + mock.mockAlertsEmptyData as AlertSearchResponse<{}, ChartCollapseAgg> + ); + expect(res).toEqual([]); + }); +}); + +describe('get group by label', () => { + test('should return correct label for group by selections', () => { + expect(getGroupByLabel('host.name')).toEqual(i18n.HOST_NAME_LABEL); + expect(getGroupByLabel('user.name')).toEqual(i18n.USER_NAME_LABEL); + expect(getGroupByLabel('source.ip')).toEqual(i18n.SOURCE_LABEL); + expect(getGroupByLabel('destination.ip')).toEqual(i18n.DESTINATION_LABEL); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/helpers.tsx new file mode 100644 index 00000000000000..12651da52415bd --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/helpers.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { has } from 'lodash'; +import type { ChartCollapseAgg, ChartCollapseData } from './types'; +import type { AlertSearchResponse } from '../../../../containers/detection_engine/alerts/types'; +import type { + SummaryChartsData, + SummaryChartsAgg, +} from '../../../../components/alerts_kpis/alerts_summary_charts_panel/types'; +import { severityLabels } from '../../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; +import { UNKNOWN_SEVERITY } from '../../../../components/alerts_kpis/severity_level_panel/translations'; + +export const parseChartCollapseData = ( + response: AlertSearchResponse<{}, ChartCollapseAgg> +): ChartCollapseData[] => { + const ret: ChartCollapseData = { rule: null, group: null, severities: [] }; + ret.rule = response?.aggregations?.topRule?.buckets?.at(0)?.key ?? null; + ret.group = response?.aggregations?.topGrouping?.buckets?.at(0)?.key ?? null; + + const severityBuckets = response?.aggregations?.severities?.buckets ?? []; + if (severityBuckets.length > 0) { + ret.severities = severityBuckets.map((severity) => { + return { + key: severity.key, + value: severity.doc_count, + label: severityLabels[severity.key] ?? UNKNOWN_SEVERITY, + }; + }); + return [ret]; + } + return []; +}; + +export const isChartCollapseData = (data: SummaryChartsData[]): data is ChartCollapseData[] => { + return data?.every((x) => has(x, 'rule') && has(x, 'group') && has(x, 'severities')); +}; + +export const isChartCollapseAgg = ( + data: AlertSearchResponse<{}, SummaryChartsAgg> +): data is AlertSearchResponse<{}, ChartCollapseAgg> => { + return ( + has(data, 'aggregations.severities') && + has(data, 'aggregations.topRule') && + has(data, 'aggregations.topGrouping') + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/index.test.tsx new file mode 100644 index 00000000000000..1327fadef7f7ed --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/index.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import type { GroupBySelection } from '../../../../components/alerts_kpis/alerts_progress_bar_panel/types'; +import { TestProviders } from '../../../../../common/mock'; +import { ChartCollapse } from '.'; +import { useSummaryChartData } from '../../../../components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import * as mock from './mock_data'; + +jest.mock('../../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); +jest.mock('../../../../components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'); + +const defaultProps = { + groupBySelection: 'host.name' as GroupBySelection, + signalIndexName: 'signalIndexName', +}; + +const severitiesId = '[data-test-subj="chart-collapse-severities"]'; +const ruleId = '[data-test-subj="chart-collapse-top-rule"]'; +const groupId = '[data-test-subj="chart-collapse-top-group"]'; + +describe('ChartCollapse', () => { + const mockUseSummaryChartData = useSummaryChartData as jest.Mock; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('it renders the chart collapse panel and the 3 summary componenets', () => { + mockUseSummaryChartData.mockReturnValue({ items: [], isLoading: false }); + const { container } = render( + + + + ); + expect(container.querySelector('[data-test-subj="chart-collapse"]')).toBeInTheDocument(); + + expect(container.querySelector(severitiesId)).toBeInTheDocument(); + expect(container.querySelector(ruleId)).toBeInTheDocument(); + expect(container.querySelector(groupId)).toBeInTheDocument(); + }); + + test('it renders chart collapse with data', () => { + mockUseSummaryChartData.mockReturnValue({ items: mock.parsedAlerts, isLoading: false }); + const { container } = render( + + + + ); + + mock.parsedAlerts.at(0)?.severities.forEach((severity) => { + expect(container.querySelector(severitiesId)).toHaveTextContent( + `${severity.label}: ${severity.value}` + ); + }); + expect(container.querySelector(ruleId)).toHaveTextContent('Top alerted rule: Test rule'); + expect(container.querySelector(groupId)).toHaveTextContent('Top alerted host: Test group'); + }); + + test('it renders chart collapse without data', () => { + mockUseSummaryChartData.mockReturnValue({ items: [], isLoading: false }); + const { container } = render( + + + + ); + mock.parsedAlerts.at(0)?.severities.forEach((severity) => { + expect(container.querySelector(severitiesId)).toHaveTextContent(`${severity.label}: 0`); + }); + expect(container.querySelector(ruleId)).toHaveTextContent('Top alerted rule: None'); + expect(container.querySelector(groupId)).toHaveTextContent('Top alerted host: None'); + }); + + test('it renders group by label correctly', () => { + mockUseSummaryChartData.mockReturnValue({ items: [], isLoading: false }); + const { container } = render( + + + + ); + expect(container.querySelector(groupId)).toHaveTextContent('Top alerted user: None'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/index.tsx index 0765f1471925b5..bd18195311f180 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/index.tsx @@ -5,26 +5,32 @@ * 2.0. */ import { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiText } from '@elastic/eui'; +import { ALERT_SEVERITY, ALERT_RULE_NAME } from '@kbn/rule-data-utils'; import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; import type { Filter, Query } from '@kbn/es-query'; import { v4 as uuid } from 'uuid'; +import { capitalize } from 'lodash'; import React, { useMemo } from 'react'; -import { ALERT_SEVERITY, ALERT_RULE_NAME } from '@kbn/rule-data-utils'; import styled from 'styled-components'; +import type { GroupBySelection } from '../../../../components/alerts_kpis/alerts_progress_bar_panel/types'; +import { getGroupByLabel } from '../../../../components/alerts_kpis/alerts_progress_bar_panel/helpers'; +import { InspectButton, InspectButtonContainer } from '../../../../../common/components/inspect'; import { useSummaryChartData } from '../../../../components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; -import { - isAlertsBySeverityData, - getSeverityColor, -} from '../../../../components/alerts_kpis/alerts_summary_charts_panel/severity_level_panel/helpers'; +import { getSeverityColor } from '../../../../components/alerts_kpis/severity_level_panel/helpers'; import { FormattedCount } from '../../../../../common/components/formatted_number'; +import { isChartCollapseData } from './helpers'; +import * as i18n from './translations'; + +import { SEVERITY_COLOR } from '../../../../../overview/components/detection_response/utils'; const DETECTIONS_ALERTS_COLLAPSED_CHART_ID = 'detectioin-alerts-collapsed-chart'; -const combinedAggregations = (stackByValue: string) => { +const combinedAggregations = (groupBySelection: GroupBySelection) => { return { - statusBySeverity: { + severities: { terms: { field: ALERT_SEVERITY, + min_doc_count: 0, }, }, topRule: { @@ -35,19 +41,29 @@ const combinedAggregations = (stackByValue: string) => { }, topGrouping: { terms: { - field: stackByValue, + field: groupBySelection, size: 1, }, }, }; }; -const ChartCollapseWrapper = styled.div` - margin: ${({ theme }) => theme.eui.euiSizeS}; +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + margin-top: ${({ theme }) => theme.eui.euiSizeXS}; + @media only screen and (min-width: ${({ theme }) => theme.eui.euiBreakpoints.l}); `; +const SeverityWrapper = styled(EuiFlexItem)` + min-width: 380px; +`; + +const StyledEuiText = styled(EuiText)` + border-left: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; + padding-left: ${({ theme }) => theme.eui.euiSizeL}; + white-space: nowrap; +`; interface Props { - stackByValue: string; + groupBySelection: GroupBySelection; filters?: Filter[]; query?: Query; signalIndexName: string | null; @@ -55,49 +71,78 @@ interface Props { } export const ChartCollapse: React.FC = ({ - stackByValue, + groupBySelection, filters, query, signalIndexName, runtimeMappings, }: Props) => { const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_COLLAPSED_CHART_ID}-${uuid()}`, []); + const aggregations = useMemo(() => combinedAggregations(groupBySelection), [groupBySelection]); - const { items } = useSummaryChartData({ - aggregations: combinedAggregations, + const { items, isLoading } = useSummaryChartData({ + aggregations, filters, query, signalIndexName, runtimeMappings, uniqueQueryId, }); - const data = useMemo(() => (isAlertsBySeverityData(items) ? items : []), [items]); + const data = useMemo(() => (isChartCollapseData(items) ? items : []), [items]); + const topRule = useMemo(() => data.at(0)?.rule ?? i18n.NO_RESULT_MESSAGE, [data]); + const topGroup = useMemo(() => data.at(0)?.group ?? i18n.NO_RESULT_MESSAGE, [data]); + const severities = useMemo(() => { + const severityData = data.at(0)?.severities ?? []; + return Object.keys(SEVERITY_COLOR).map((severity) => { + const obj = severityData.find((s) => s.key === severity); + if (obj) { + return { key: obj.key, label: obj.label, value: obj.value }; + } else { + return { key: severity, label: capitalize(severity), value: 0 }; + } + }); + }, [data]); + const groupBy = useMemo(() => getGroupByLabel(groupBySelection), [groupBySelection]); + // className="eui-alignMiddle" return ( - - - - - {data.map((severity) => ( - - - - - {`${severity.label}:`} - - - + + {!isLoading && ( + + + + {severities.map((severity) => ( + + + {`${severity.label}: `} - - - - ))} + + + ))} + + + + + {i18n.TOP_RULE_TITLE} + {topRule} + + + + + + {`${i18n.TOP_GROUP_TITLE} ${groupBy}: `} + {topGroup} + + + + + - - - + + )} +
); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/mock_data.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/mock_data.ts new file mode 100644 index 00000000000000..0b7ba68c99ec67 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/mock_data.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +const from = '2022-04-05T12:00:00.000Z'; +const to = '2022-04-08T12:00:00.000Z'; + +export const mockAlertsData = { + took: 0, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 570, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + severities: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'high', + doc_count: 78, + }, + { + key: 'low', + doc_count: 46, + }, + { + key: 'medium', + doc_count: 32, + }, + { + key: 'critical', + doc_count: 21, + }, + ], + }, + topRule: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Test rule', + doc_count: 234, + }, + ], + }, + topGrouping: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Test group', + doc_count: 100, + }, + ], + }, + }, +}; + +export const mockAlertsEmptyData = { + took: 0, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 0, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + severities: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + topRule: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + topGrouping: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, +}; + +export const query = { + size: 0, + query: { + bool: { + filter: [ + { bool: { filter: [], must: [], must_not: [], should: [] } }, + { range: { '@timestamp': { gte: from, lte: to } } }, + ], + }, + }, + aggs: { + severities: { + terms: { + field: 'kibana.alert.severity', + }, + }, + topRule: { + terms: { + field: 'kibana.alert.rule.name', + size: 1000, + }, + }, + topGrouping: { + terms: { + field: 'host.name', + size: 1, + }, + }, + }, + runtime_mappings: undefined, +}; + +export const parsedAlerts = [ + { + rule: 'Test rule', + group: 'Test group', + severities: [ + { key: 'high', value: 78, label: 'High' }, + { key: 'low', value: 46, label: 'Low' }, + { key: 'medium', value: 32, label: 'Medium' }, + { key: 'critical', value: 21, label: 'Critical' }, + ], + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/translations.ts new file mode 100644 index 00000000000000..adc53e8859f394 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const TOP_RULE_TITLE = i18n.translate( + 'xpack.securitySolution.components.chartCollapse.topRule', + { + defaultMessage: 'Top alerted rule: ', + } +); + +export const TOP_GROUP_TITLE = i18n.translate( + 'xpack.securitySolution.components.chartCollapse.topGroup', + { + defaultMessage: 'Top alerted', + } +); + +export const NO_RESULT_MESSAGE = i18n.translate( + 'xpack.securitySolution.components.chartCollapse.noResultMessage', + { + defaultMessage: 'None', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/types.ts index 9feccb45423f86..1dfa7ea3163728 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/types.ts @@ -5,10 +5,13 @@ * 2.0. */ import type { BucketItem } from '../../../../../../common/search_strategy/security_solution/cti'; -import type { SeverityBucket } from '../../../../../overview/components/detection_response/alerts_by_status/types'; +import type { + SeverityBucket, + SeverityBuckets as SeverityData, +} from '../../../../../overview/components/detection_response/alerts_by_status/types'; export interface ChartCollapseAgg { - statusBySeverity: { + severities: { doc_count_error_upper_bound: number; sum_other_doc_count: number; buckets: SeverityBucket[]; @@ -25,8 +28,7 @@ export interface ChartCollapseAgg { }; } export interface ChartCollapseData { - key: string; - value: number; - percentage: number; - label: string; + rule: string | null; + group: string | null; + severities: SeverityData[]; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts index fc24e1777f186b..60f55d79c895ea 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts @@ -104,33 +104,37 @@ describe('helpers', () => { describe('getOptionProperties', () => { test('it returns the expected properties when alertViewSelection is Trend', () => { expect(getOptionProperties(TREND_ID)).toEqual({ - id: CHARTS_ID, - 'data-test-subj': TREND_ID, + id: TREND_ID, + 'data-test-subj': `chart-select-${TREND_ID}`, label: i18n.TREND_TITLE, + value: TREND_ID, }); }); test('it returns the expected properties when alertViewSelection is Table', () => { expect(getOptionProperties(TABLE_ID)).toEqual({ id: TABLE_ID, - 'data-test-subj': TABLE_ID, - label: i18n.TABLE, + 'data-test-subj': `chart-select-${TABLE_ID}`, + label: i18n.TABLE_TITLE, + value: TABLE_ID, }); }); test('it returns the expected properties when alertViewSelection is Treemap', () => { expect(getOptionProperties(TREEMAP_ID)).toEqual({ id: TREEMAP_ID, - 'data-test-subj': TREEMAP_ID, - label: i18n.TREEMAP, + 'data-test-subj': `chart-select-${TREEMAP_ID}`, + label: i18n.TREEMAP_TITLE, + value: TREEMAP_ID, }); }); test('it returns the expected properties when alertViewSelection is charts', () => { expect(getOptionProperties(CHARTS_ID)).toEqual({ id: CHARTS_ID, - 'data-test-subj': CHARTS_ID, - label: i18n.CHARTS, + 'data-test-subj': `chart-select-${CHARTS_ID}`, + label: i18n.CHARTS_TITLE, + value: CHARTS_ID, }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts index 0ebe4fb80e4e7f..24d85b251e1cb5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts @@ -96,19 +96,35 @@ export const getContextMenuPanels = ({ export const getOptionProperties = ( alertViewSelection: AlertViewSelection ): EuiButtonGroupOptionProps => { - const charts = { id: CHARTS_ID, 'data-test-subj': alertViewSelection, label: i18n.CHARTS_TITLE }; + const charts = { + id: CHARTS_ID, + 'data-test-subj': `chart-select-${CHARTS_ID}`, + label: i18n.CHARTS_TITLE, + value: CHARTS_ID, + }; switch (alertViewSelection) { case TABLE_ID: - return { id: TABLE_ID, 'data-test-subj': alertViewSelection, label: i18n.TABLE_TITLE }; + return { + id: TABLE_ID, + 'data-test-subj': `chart-select-${TABLE_ID}`, + label: i18n.TABLE_TITLE, + value: TABLE_ID, + }; case TREND_ID: return { id: TREND_ID, - 'data-test-subj': alertViewSelection, + 'data-test-subj': `chart-select-${TREND_ID}`, label: i18n.TREND_TITLE, + value: TREND_ID, }; case TREEMAP_ID: - return { id: TREEMAP_ID, 'data-test-subj': alertViewSelection, label: i18n.TREEMAP_TITLE }; + return { + id: TREEMAP_ID, + 'data-test-subj': `chart-select-${TREEMAP_ID}`, + label: i18n.TREEMAP_TITLE, + value: TREEMAP_ID, + }; case CHARTS_ID: return charts; default: diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.test.tsx index a666a64f7941e9..a5110a96a56e92 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.test.tsx @@ -4,42 +4,82 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import React from 'react'; import { TestProviders } from '../../../../../common/mock'; -import { SELECT_A_CHART_ARIA_LABEL, TREEMAP } from './translations'; +import * as i18n from './translations'; import { ChartSelect } from '.'; +import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; + +const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; +jest.mock('../../../../../common/hooks/use_experimental_features'); describe('ChartSelect', () => { - test('it renders the chart select button', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('it renders the chart select button when alertsPageChartsEnabled is false', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); render( ); - expect(screen.getByRole('button', { name: SELECT_A_CHART_ARIA_LABEL })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: i18n.SELECT_A_CHART_ARIA_LABEL }) + ).toBeInTheDocument(); }); - test('it invokes `setAlertViewSelection` with the expected value when a chart is selected', async () => { + test('it invokes `setAlertViewSelection` with the expected value when a chart is selected and alertsPageChartsEnabled is false', async () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); const setAlertViewSelection = jest.fn(); - render( ); - const selectButton = screen.getByRole('button', { name: SELECT_A_CHART_ARIA_LABEL }); + const selectButton = screen.getByRole('button', { name: i18n.SELECT_A_CHART_ARIA_LABEL }); selectButton.click(); await waitForEuiPopoverOpen(); - const treemapMenuItem = screen.getByRole('button', { name: TREEMAP }); + const treemapMenuItem = screen.getByRole('button', { name: i18n.TREEMAP }); treemapMenuItem.click(); expect(setAlertViewSelection).toBeCalledWith('treemap'); }); + + test('it renders the chart select tabs when alertsPageChartsEnabled is true', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + render( + + + + ); + + expect(screen.getByTestId('chart-select-tabs')).toBeInTheDocument(); + expect(screen.getByTestId('trend')).toBeChecked(); + }); + + test('changing selection render correctly when alertsPageChartsEnabled is true', async () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + const setAlertViewSelection = jest.fn(); + const { container } = render( + + + + ); + + const button = container.querySelector('input[value="treemap"]'); + if (button) { + fireEvent.change(button, { target: { checked: true, type: 'radio' } }); + } + expect(screen.getByTestId('treemap')).toBeChecked(); + expect(screen.getByTestId('trend')).not.toBeChecked(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx index 2748050bb92f7c..c31e04afeb301e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx @@ -70,13 +70,14 @@ const ChartSelectComponent: React.FC = ({ <> {isAlertsPageChartsEnabled ? ( setAlertViewSelection(id as AlertViewSelection)} buttonSize="compressed" color="primary" + data-test-subj="chart-select-tabs" /> ) : ( { }; }); +const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; +jest.mock('../../../../common/hooks/use_experimental_features'); + +const mockSetToggle = jest.fn(); +const mockUseQueryToggle = useQueryToggle as jest.Mock; +jest.mock('../../../../common/containers/query_toggle'); + const defaultAlertSettings = { alertViewSelection: 'trend', countTableStackBy0: 'kibana.alert.rule.name', countTableStackBy1: 'host.name', isTreemapPanelExpanded: true, + groupBySelection: 'host.name', riskChartStackBy0: 'kibana.alert.rule.name', riskChartStackBy1: 'host.name', setAlertViewSelection: jest.fn(), setCountTableStackBy0: jest.fn(), setCountTableStackBy1: jest.fn(), + setGroupBySelection: jest.fn(), setIsTreemapPanelExpanded: jest.fn(), setRiskChartStackBy0: jest.fn(), setRiskChartStackBy1: jest.fn(), @@ -78,7 +89,7 @@ const defaultAlertSettings = { const defaultProps = { addFilter: jest.fn(), - alertsHistogramDefaultFilters: [ + alertsDefaultFilters: [ { meta: { alias: null, @@ -136,6 +147,8 @@ const resetGroupByFields = () => { describe('ChartPanels', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); (useSourcererDataView as jest.Mock).mockReturnValue({ indicesExist: true, @@ -148,7 +161,7 @@ describe('ChartPanels', () => { }); }); - test('it renders the chart selector', async () => { + test('it renders the chart selector when alertsPageChartsEnabled is false', async () => { render( @@ -160,6 +173,34 @@ describe('ChartPanels', () => { }); }); + test('it renders the chart selector tabs when alertsPageChartsEnabled is true and toggle is true', async () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('chart-select-tabs')).toBeInTheDocument(); + }); + }); + + test('it renders the chart collapse when alertsPageChartsEnabled is true and toggle is false', async () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('chart-collapse')).toBeInTheDocument(); + }); + }); + test('it renders the trend loading spinner when data is loading and `alertViewSelection` is trend', async () => { render( @@ -315,7 +356,7 @@ describe('ChartPanels', () => { }); }); - test('it renders the alerts count panel when `alertViewSelection` is treemap', async () => { + test('it renders the treemap panel when `alertViewSelection` is treemap', async () => { (useAlertsLocalStorage as jest.Mock).mockReturnValue({ ...defaultAlertSettings, alertViewSelection: 'treemap', @@ -331,4 +372,38 @@ describe('ChartPanels', () => { expect(screen.getByTestId('treemapPanel')).toBeInTheDocument(); }); }); + + test('it renders the charts loading spinner when data is loading and `alertViewSelection` is charts', async () => { + (useAlertsLocalStorage as jest.Mock).mockReturnValue({ + ...defaultAlertSettings, + alertViewSelection: 'charts', + }); + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('chartsLoadingSpinner')).toBeInTheDocument(); + }); + }); + + test('it renders the charts panel when `alertViewSelection` is charts', async () => { + (useAlertsLocalStorage as jest.Mock).mockReturnValue({ + ...defaultAlertSettings, + alertViewSelection: 'charts', + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('chartPanels')).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx index 113261ba0b22fd..d26a3e6ccf2ed2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx @@ -8,8 +8,8 @@ import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; import type { Filter, Query } from '@kbn/es-query'; -import { EuiFlexItem, EuiLoadingContent, EuiLoadingSpinner } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import { EuiFlexItem, EuiLoadingContent } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; @@ -17,6 +17,7 @@ import { useAlertsLocalStorage } from './alerts_local_storage'; import type { AlertsSettings } from './alerts_local_storage/types'; import { ChartContextMenu } from './chart_context_menu'; import { ChartSelect } from './chart_select'; +import { ChartCollapse } from './chart_collapse'; import * as i18n from './chart_select/translations'; import { AlertsTreemapPanel } from '../../../../common/components/alerts_treemap_panel'; import type { UpdateDateRange } from '../../../../common/components/charts/common'; @@ -49,7 +50,7 @@ const ChartSelectContainer = styled.div` export interface Props { addFilter: ({ field, value }: { field: string; value: string | number }) => void; - alertsHistogramDefaultFilters: Filter[]; + alertsDefaultFilters: Filter[]; isLoadingIndexPattern: boolean; query: Query; runtimeMappings: MappingRuntimeFields; @@ -59,7 +60,7 @@ export interface Props { const ChartPanelsComponent: React.FC = ({ addFilter, - alertsHistogramDefaultFilters, + alertsDefaultFilters, isLoadingIndexPattern, query, runtimeMappings, @@ -69,19 +70,20 @@ const ChartPanelsComponent: React.FC = ({ const { toggleStatus: isExpanded, setToggleStatus: setIsExpanded } = useQueryToggle( DETECTIONS_ALERTS_CHARTS_PANEL_ID ); - const [stackByValue, _] = useState('host.name'); const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); const { alertViewSelection, countTableStackBy0, countTableStackBy1, + groupBySelection, isTreemapPanelExpanded, riskChartStackBy0, riskChartStackBy1, setAlertViewSelection, setCountTableStackBy0, setCountTableStackBy1, + setGroupBySelection, setIsTreemapPanelExpanded, setRiskChartStackBy0, setRiskChartStackBy1, @@ -176,7 +178,13 @@ const ChartPanelsComponent: React.FC = ({ /> ) : ( -

{stackByValue}

// collapsed + ); } else { return ( @@ -193,7 +201,11 @@ const ChartPanelsComponent: React.FC = ({ setAlertViewSelection, isAlertsPageChartsEnabled, isExpanded, - stackByValue, + groupBySelection, + alertsDefaultFilters, + query, + signalIndexName, + runtimeMappings, ]); return ( @@ -210,7 +222,7 @@ const ChartPanelsComponent: React.FC = ({ comboboxRef={stackByField0ComboboxRef} defaultStackByOption={trendChartStackBy} extraActions={resetGroupByFieldAction} - filters={alertsHistogramDefaultFilters} + filters={alertsDefaultFilters} inspectTitle={i18n.TREND} onFieldSelected={updateCommonStackBy0} panelHeight={TREND_CHART_PANEL_HEIGHT} @@ -241,7 +253,7 @@ const ChartPanelsComponent: React.FC = ({ alignHeader="flexStart" chartOptionsContextMenu={chartOptionsContextMenu} extraActions={resetGroupByFieldAction} - filters={alertsHistogramDefaultFilters} + filters={alertsDefaultFilters} inspectTitle={i18n.TABLE} panelHeight={TABLE_PANEL_HEIGHT} query={query} @@ -272,9 +284,9 @@ const ChartPanelsComponent: React.FC = ({ addFilter={addFilter} alignHeader="flexStart" chartOptionsContextMenu={chartOptionsContextMenu} - filters={alertsHistogramDefaultFilters} inspectTitle={i18n.TREEMAP} isPanelExpanded={isAlertsPageChartsEnabled ? isExpanded : isTreemapPanelExpanded} + filters={alertsDefaultFilters} query={query} riskSubAggregationField="kibana.alert.risk_score" setIsPanelExpanded={ @@ -298,12 +310,12 @@ const ChartPanelsComponent: React.FC = ({ {isAlertsPageChartsEnabled && alertViewSelection === 'charts' && ( {isLoadingIndexPattern ? ( - + ) : ( = ({ runtimeMappings={runtimeMappings} isExpanded={isExpanded} setIsExpanded={setIsExpanded} + groupBySelection={groupBySelection} + setGroupBySelection={setGroupBySelection} /> )} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index dfb4b0c299d0c7..de109fbd691d1a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -217,7 +217,7 @@ const DetectionEnginePageComponent: React.FC = ({ [formatUrl, navigateToUrl] ); - const alertsHistogramDefaultFilters = useMemo( + const alertsDefaultFilters = useMemo( () => [ ...filters, ...buildShowBuildingBlockFilter(showBuildingBlockAlerts), @@ -437,7 +437,7 @@ const DetectionEnginePageComponent: React.FC = ({