diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 52ab4a8cb8d73..5863d525ca0f8 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/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index 24d8ad383b73f..046962a5d9685 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -32,8 +32,6 @@ export const ALERTS_COUNT = export const ALERTS_TREND_SIGNAL_RULE_NAME_PANEL = '[data-test-subj="render-content-kibana.alert.rule.name"]'; -export const CHART_SELECT = '[data-test-subj="chartSelect"]'; - export const CLOSE_ALERT_BTN = '[data-test-subj="close-alert-status"]'; export const CLOSE_SELECTED_ALERTS_BTN = '[data-test-subj="close-alert-status"]'; @@ -92,7 +90,7 @@ export const RULE_NAME = '[data-test-subj^=formatted-field][data-test-subj$=rule export const SELECTED_ALERTS = '[data-test-subj="selectedShowBulkActionsButton"]'; -export const SELECT_TABLE = '[data-test-subj="table"]'; +export const SELECT_AGGREGATION_CHART = '[data-test-subj="chart-select-table"]'; export const SEND_ALERT_TO_TIMELINE_BTN = '[data-test-subj="send-alert-to-timeline-button"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index 9dfb269433865..c7810db6ae21d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -8,7 +8,6 @@ import { ADD_EXCEPTION_BTN, ALERT_CHECKBOX, - CHART_SELECT, CLOSE_ALERT_BTN, CLOSE_SELECTED_ALERTS_BTN, EXPAND_ALERT_BTN, @@ -18,7 +17,7 @@ import { MARK_ALERT_ACKNOWLEDGED_BTN, OPEN_ALERT_BTN, SEND_ALERT_TO_TIMELINE_BTN, - SELECT_TABLE, + SELECT_AGGREGATION_CHART, TAKE_ACTION_POPOVER_BTN, TIMELINE_CONTEXT_MENU_BTN, CLOSE_FLYOUT, @@ -257,8 +256,7 @@ export const openAlerts = () => { }; export const selectCountTable = () => { - cy.get(CHART_SELECT).click({ force: true }); - cy.get(SELECT_TABLE).click(); + cy.get(SELECT_AGGREGATION_CHART).click({ force: true }); }; export const clearGroupByTopInput = () => { diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.test.tsx index 8f53a3f226ce6..75f641501dc52 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.test.tsx @@ -49,7 +49,7 @@ describe('AlertsTreemap', () => { }); test('it renders the treemap', () => { - expect(screen.getByTestId('treemap').querySelector('.echChart')).toBeInTheDocument(); + expect(screen.getByTestId('alerts-treemap').querySelector('.echChart')).toBeInTheDocument(); }); test('it renders the legend with the expected overflow-y style', () => { @@ -71,7 +71,7 @@ describe('AlertsTreemap', () => { }); test('it does NOT render the treemap', () => { - expect(screen.queryByTestId('treemap')).not.toBeInTheDocument(); + expect(screen.queryByTestId('alerts-treemap')).not.toBeInTheDocument(); }); test('it does NOT render the legend', () => { @@ -127,7 +127,7 @@ describe('AlertsTreemap', () => { }); test('it renders the treemap', () => { - expect(screen.getByTestId('treemap').querySelector('.echChart')).toBeInTheDocument(); + expect(screen.getByTestId('alerts-treemap').querySelector('.echChart')).toBeInTheDocument(); }); test('it does NOT render the "no data" message', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.tsx index fc6edda0c90b9..1683bba36c1c7 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.tsx @@ -31,7 +31,7 @@ import { NoData } from './no_data'; import { NO_DATA_REASON_LABEL } from './translations'; import type { AlertsTreeMapAggregation, FlattenedBucket, RawBucket } from './types'; -export const DEFAULT_MIN_CHART_HEIGHT = 370; // px +export const DEFAULT_MIN_CHART_HEIGHT = 240; // px const DEFAULT_LEGEND_WIDTH = 300; // px export interface Props { @@ -165,7 +165,7 @@ const AlertsTreemapComponent: React.FC = ({ } return ( -
+
{stackByField1 != null && !isEmpty(stackByField1) && normalizedData.length === 0 ? ( diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx index 0db21ee27ea75..5a3f4b3e25e0e 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx @@ -214,7 +214,7 @@ describe('AlertsTreemapPanel', () => { ); - await waitFor(() => expect(screen.getByTestId('chartSelect')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('chart-select-tabs')).toBeInTheDocument()); }); it('renders field selection when `isPanelExpanded` is true', async () => { @@ -305,6 +305,6 @@ describe('AlertsTreemapPanel', () => { ); - await waitFor(() => expect(screen.getByTestId('treemap')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('alerts-treemap')).toBeInTheDocument()); }); }); 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 ad357b81b5e22..94246f6932178 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', @@ -59,6 +59,12 @@ const getAggregateAlerts = ( return ret; }; -export const isAlertsTypeData = (data: SummaryChartsData[]): data is AlertsTypeData[] => { +export const getIsAlertsTypeData = (data: SummaryChartsData[]): data is AlertsTypeData[] => { return data?.every((x) => has(x, 'type')); }; + +export const getIsAlertsByTypeAgg = ( + 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_by_type_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/index.tsx index 666be899d2a17..391057796dd7d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/index.tsx @@ -14,7 +14,7 @@ import { HeaderSection } from '../../../../common/components/header_section'; import { InspectButtonContainer } from '../../../../common/components/inspect'; import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data'; import { alertTypeAggregations } from '../alerts_summary_charts_panel/aggregations'; -import { isAlertsTypeData } from './helpers'; +import { getIsAlertsTypeData } from './helpers'; import * as i18n from './translations'; const ALERTS_BY_TYPE_CHART_ID = 'alerts-summary-alert_by_type'; @@ -37,7 +37,7 @@ export const AlertsByTypePanel: React.FC = ({ skip, uniqueQueryId, }); - const data = useMemo(() => (isAlertsTypeData(items) ? items : []), [items]); + const data = useMemo(() => (getIsAlertsTypeData(items) ? items : []), [items]); return ( 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 cc2e5ca8c78d0..067b5e2e86f14 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 c0b4d8bff6dfa..8dbfb949cdaec 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 @@ -5,6 +5,7 @@ * 2.0. */ +import { EuiProgress } from '@elastic/eui'; import type { EuiComboBox } from '@elastic/eui'; import type { Action } from '@kbn/ui-actions-plugin/public'; import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; @@ -54,6 +55,8 @@ interface AlertsCountPanelProps { stackByField1ComboboxRef?: React.RefObject>; stackByWidth?: number; title?: React.ReactNode; + isExpanded?: boolean; + setIsExpanded?: (status: boolean) => void; } const CHART_HEIGHT = '180px'; @@ -78,9 +81,12 @@ export const AlertsCountPanel = memo( stackByField1ComboboxRef, stackByWidth, title = i18n.COUNT_TABLE_TITLE, + isExpanded, + 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()}`, []); @@ -102,20 +108,30 @@ export const AlertsCountPanel = memo( }, [query, filters]); const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_COUNT_ID); - const [querySkip, setQuerySkip] = useState(!toggleStatus); + const [querySkip, setQuerySkip] = useState( + isAlertsPageChartsEnabled ? !isExpanded : !toggleStatus + ); useEffect(() => { - setQuerySkip(!toggleStatus); - }, [toggleStatus]); + if (isAlertsPageChartsEnabled) { + setQuerySkip(!isExpanded); + } else { + setQuerySkip(!toggleStatus); + } + }, [toggleStatus, isAlertsPageChartsEnabled, isExpanded]); + const toggleQuery = useCallback( (newToggleStatus: boolean) => { - setToggleStatus(newToggleStatus); + if (isAlertsPageChartsEnabled && setIsExpanded) { + setIsExpanded(newToggleStatus); + } else { + setToggleStatus(newToggleStatus); + } // toggle on = skipQuery false setQuerySkip(!newToggleStatus); }, - [setQuerySkip, setToggleStatus] + [setQuerySkip, setToggleStatus, setIsExpanded, isAlertsPageChartsEnabled] ); - const isChartEmbeddablesEnabled = useIsExperimentalFeatureEnabled('chartEmbeddablesEnabled'); const timerange = useMemo(() => ({ from, to }), [from, to]); const extraVisualizationOptions = useMemo( @@ -176,11 +192,19 @@ export const AlertsCountPanel = memo( setQuery, uniqueQueryId, }); + const showCount = useMemo(() => { + if (isAlertsPageChartsEnabled) { + return isExpanded; + } + return toggleStatus; + }, [isAlertsPageChartsEnabled, toggleStatus, isExpanded]); return ( - + ( titleSize="s" hideSubtitle showInspectButton={chartOptionsContextMenu == null} - toggleStatus={toggleStatus} + toggleStatus={isAlertsPageChartsEnabled ? isExpanded : toggleStatus} toggleQuery={toggleQuery} > ( uniqueQueryId={uniqueQueryId} /> - {toggleStatus ? ( - - ) : null} + {showCount && + (isLoadingAlerts ? ( + + ) : ( + + ))} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx index a90b86dc4f4cd..347f207266524 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx @@ -109,24 +109,23 @@ jest.mock('../common/hooks', () => { }; }); -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 c948eb26a1ba8..de52db990d5c7 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 @@ -109,6 +109,8 @@ interface AlertsHistogramPanelProps { titleSize?: EuiTitleSize; updateDateRange: UpdateDateRange; hideQueryToggle?: boolean; + isExpanded?: boolean; + setIsExpanded?: (status: boolean) => void; } const NO_LEGEND_DATA: LegendItem[] = []; @@ -147,6 +149,8 @@ export const AlertsHistogramPanel = memo( titleSize = 'm', updateDateRange, hideQueryToggle = false, + isExpanded, + setIsExpanded, }) => { const { to, from, deleteQuery, setQuery } = useGlobalTime(false); @@ -159,6 +163,9 @@ export const AlertsHistogramPanel = memo( const [selectedStackByOption, setSelectedStackByOption] = useState( onlyField == null ? defaultStackByOption : onlyField ); + const isChartEmbeddablesEnabled = useIsExperimentalFeatureEnabled('chartEmbeddablesEnabled'); + const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); + const onSelect = useCallback( (field: string) => { setSelectedStackByOption(field); @@ -174,20 +181,30 @@ export const AlertsHistogramPanel = memo( }, [defaultStackByOption, onlyField]); const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_HISTOGRAM_ID); - const [querySkip, setQuerySkip] = useState(!toggleStatus); + const [querySkip, setQuerySkip] = useState( + isAlertsPageChartsEnabled ? !isExpanded : !toggleStatus + ); useEffect(() => { - setQuerySkip(!toggleStatus); - }, [toggleStatus]); + if (isAlertsPageChartsEnabled && isExpanded !== undefined) { + setQuerySkip(!isExpanded); + } else { + setQuerySkip(!toggleStatus); + } + }, [toggleStatus, isAlertsPageChartsEnabled, isExpanded]); + const toggleQuery = useCallback( (newToggleStatus: boolean) => { - setToggleStatus(newToggleStatus); + if (isAlertsPageChartsEnabled && setIsExpanded !== undefined) { + setIsExpanded(newToggleStatus); + } else { + setToggleStatus(newToggleStatus); + } // toggle on = skipQuery false setQuerySkip(!newToggleStatus); }, - [setQuerySkip, setToggleStatus] + [setQuerySkip, setToggleStatus, setIsExpanded, isAlertsPageChartsEnabled] ); - const isChartEmbeddablesEnabled = useIsExperimentalFeatureEnabled('chartEmbeddablesEnabled'); const timerange = useMemo(() => ({ from, to }), [from, to]); const { @@ -347,14 +364,28 @@ export const AlertsHistogramPanel = memo( [onlyField, title] ); + 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]); + return ( - + ( outerDirection="row" title={titleText} titleSize={titleSize} - toggleStatus={toggleStatus} + toggleStatus={showHistogram} toggleQuery={hideQueryToggle ? undefined : toggleQuery} showInspectButton={isChartEmbeddablesEnabled ? false : chartOptionsContextMenu == null} subtitle={!isInitialLoading && showTotalAlertsCount && totalAlerts} @@ -410,12 +441,10 @@ export const AlertsHistogramPanel = memo( {chartOptionsContextMenu(uniqueQueryId)} )} - {linkButton} - - {toggleStatus ? ( + {showHistogram ? ( isChartEmbeddablesEnabled ? ( { 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 e20f10543845e..378544084228d 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'; @@ -18,25 +18,32 @@ const ProgressWrapper = styled.div` const StyledEuiText = styled(EuiText)` margin-top: -${({ theme }) => theme.eui.euiSizeM}; `; - +const StyledEuiProgress = styled(EuiProgress)` + margin-top: ${({ theme }) => theme.eui.euiSizeS}; + margin-bottom: ${({ theme }) => theme.eui.euiSizeS}; +`; 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 ? ( + + ) : ( + + )} {!isLoading && data.length === 0 ? ( <> @@ -64,7 +71,7 @@ export const AlertsProgressBar: React.FC = ({ ) : ( { return data?.every((x) => has(x, 'percentage')); }; + +export const getIsAlertsByGroupingAgg = ( + 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 5016e98140d32..776e4fffc89ab 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 0baeeb8e36d08..abcfebc17a7ee 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 @@ -4,41 +4,57 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiPanel, EuiLoadingSpinner } from '@elastic/eui'; -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { EuiPanel } from '@elastic/eui'; +import React, { 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'; import { AlertsProgressBar } from './alerts_progress_bar'; import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data'; import { alertsGroupingAggregations } from '../alerts_summary_charts_panel/aggregations'; -import { showInitialLoadingSpinner } from '../alerts_histogram_panel/helpers'; -import { isAlertsProgressBarData } from './helpers'; +import { getIsAlertsProgressBarData } 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, @@ -49,19 +65,14 @@ export const AlertsProgressBarPanel: React.FC = ({ skip, uniqueQueryId, }); - const data = useMemo(() => (isAlertsProgressBarData(items) ? items : []), [items]); - useEffect(() => { - if (!showInitialLoadingSpinner({ isInitialLoading, isLoadingAlerts: isLoading })) { - setIsInitialLoading(false); - } - }, [isInitialLoading, isLoading, setIsInitialLoading]); + const data = useMemo(() => (getIsAlertsProgressBarData(items) ? items : []), [items]); return ( = ({ > - {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 514aff9fef4e1..da6b33ad60c08 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 efc278f9b4c47..7b5b2042a6339 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 80151de9252bc..e4883c022711e 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 6ea064bf7b7be..a6985bd7901f5 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,22 +4,31 @@ * 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, getIsAlertsBySeverityAgg } from '../severity_level_panel/helpers'; +import { parseAlertsTypeData, getIsAlertsByTypeAgg } from '../alerts_by_type_panel/helpers'; +import { + parseAlertsGroupingData, + getIsAlertsByGroupingAgg, +} from '../alerts_progress_bar_panel/helpers'; +import { + parseChartCollapseData, + getIsChartCollapseAgg, +} from '../../../pages/detection_engine/chart_panels/chart_collapse/helpers'; export const parseData = (data: AlertSearchResponse<{}, SummaryChartsAgg>) => { - if (isAlertsBySeverityAgg(data)) { + if (getIsAlertsBySeverityAgg(data)) { return parseSeverityData(data); } - if (isAlertsByTypeAgg(data)) { + if (getIsAlertsByTypeAgg(data)) { return parseAlertsTypeData(data); } - if (isAlertsByGroupingAgg(data)) { + if (getIsAlertsByGroupingAgg(data)) { return parseAlertsGroupingData(data); } + if (getIsChartCollapseAgg(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 ac5d20fcdcfa9..f02043f3d7c55 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 951aeefa1f82f..f7c8889bfc8ec 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 @@ -5,7 +5,7 @@ * 2.0. */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect, useMemo } from 'react'; import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; import type { Filter, Query } from '@kbn/es-query'; import styled from 'styled-components'; @@ -16,6 +16,8 @@ import { SeverityLevelPanel } from '../severity_level_panel'; 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}); @@ -36,6 +38,10 @@ interface Props { signalIndexName: string | null; title?: React.ReactNode; runtimeMappings?: MappingRuntimeFields; + isExpanded?: boolean; + setIsExpanded?: (status: boolean) => void; + groupBySelection: GroupBySelection; + setGroupBySelection: (groupBySelection: GroupBySelection) => void; } export const AlertsSummaryChartsPanel: React.FC = ({ @@ -47,25 +53,53 @@ export const AlertsSummaryChartsPanel: React.FC = ({ runtimeMappings, signalIndexName, title = i18n.CHARTS_TITLE, + isExpanded, + setIsExpanded, + groupBySelection, + setGroupBySelection, }: Props) => { - const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_CHARTS_ID); - const [querySkip, setQuerySkip] = useState(!toggleStatus); + const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); + const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_CHARTS_ID); + const [querySkip, setQuerySkip] = useState( + isAlertsPageChartsEnabled ? !isExpanded : !toggleStatus + ); useEffect(() => { - setQuerySkip(!toggleStatus); - }, [toggleStatus]); + if (isAlertsPageChartsEnabled) { + setQuerySkip(!isExpanded); + } else { + setQuerySkip(!toggleStatus); + } + }, [toggleStatus, isAlertsPageChartsEnabled, isExpanded]); + const toggleQuery = useCallback( (status: boolean) => { - setToggleStatus(status); + if (isAlertsPageChartsEnabled && setIsExpanded) { + setIsExpanded(status); + } else { + setToggleStatus(status); + } // toggle on = skipQuery false setQuerySkip(!status); }, - [setQuerySkip, setToggleStatus] + [setQuerySkip, setToggleStatus, setIsExpanded, isAlertsPageChartsEnabled] ); + const status: boolean = useMemo(() => { + if (isAlertsPageChartsEnabled && isExpanded) { + return true; + } + if (!isAlertsPageChartsEnabled && toggleStatus) { + return true; + } + return false; + }, [isAlertsPageChartsEnabled, isExpanded, toggleStatus]); + return ( = ({ titleSize="s" hideSubtitle showInspectButton={false} - toggleStatus={toggleStatus} + toggleStatus={isAlertsPageChartsEnabled ? isExpanded : toggleStatus} toggleQuery={toggleQuery} /> - {toggleStatus && ( + {status && ( = ({ 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 6e25ac136281a..8d3821ce1db62 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 165bbef35fcfc..86b4a5f0eae28 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'; @@ -35,6 +35,12 @@ export const parseSeverityData = ( }); }; -export const isAlertsBySeverityData = (data: SummaryChartsData[]): data is SeverityData[] => { +export const getIsAlertsBySeverityData = (data: SummaryChartsData[]): data is SeverityData[] => { return data?.every((x) => has(x, 'key')); }; + +export const getIsAlertsBySeverityAgg = ( + data: AlertSearchResponse<{}, SummaryChartsAgg> +): data is AlertSearchResponse<{}, AlertsBySeverityAgg> => { + return has(data, 'aggregations.statusBySeverity'); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx index 04ad4cbed8572..aa2583cd9d5c5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx @@ -13,7 +13,7 @@ import { HeaderSection } from '../../../../common/components/header_section'; import { InspectButtonContainer } from '../../../../common/components/inspect'; import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data'; import { severityAggregations } from '../alerts_summary_charts_panel/aggregations'; -import { isAlertsBySeverityData } from './helpers'; +import { getIsAlertsBySeverityData } from './helpers'; import { SeverityLevelChart } from './severity_level_chart'; import * as i18n from './translations'; @@ -38,7 +38,7 @@ export const SeverityLevelPanel: React.FC = ({ skip, uniqueQueryId, }); - const data = useMemo(() => (isAlertsBySeverityData(items) ? items : []), [items]); + const data = useMemo(() => (getIsAlertsBySeverityData(items) ? items : []), [items]); return ( 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 6df717c6b541e..8dc25558f4b6f 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 6731bee771a3d..1787955fb1b52 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 5ac129b6368e3..4af424c0ead8b 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 e909fc66f11db..88a73579c764f 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 0000000000000..ff03ddb279b72 --- /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 0000000000000..c6ba7ce42836f --- /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 getIsChartCollapseData = (data: SummaryChartsData[]): data is ChartCollapseData[] => { + return data?.every((x) => has(x, 'rule') && has(x, 'group') && has(x, 'severities')); +}; + +export const getIsChartCollapseAgg = ( + 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 0000000000000..1327fadef7f7e --- /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 new file mode 100644 index 0000000000000..a09397cfbd485 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 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 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 { getSeverityColor } from '../../../../components/alerts_kpis/severity_level_panel/helpers'; +import { FormattedCount } from '../../../../../common/components/formatted_number'; +import { getIsChartCollapseData } 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 = (groupBySelection: GroupBySelection) => { + return { + severities: { + terms: { + field: ALERT_SEVERITY, + min_doc_count: 0, + }, + }, + topRule: { + terms: { + field: ALERT_RULE_NAME, + size: 1, + }, + }, + topGrouping: { + terms: { + field: groupBySelection, + size: 1, + }, + }, + }; +}; + +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 { + groupBySelection: GroupBySelection; + filters?: Filter[]; + query?: Query; + signalIndexName: string | null; + runtimeMappings?: MappingRuntimeFields; +} + +export const ChartCollapse: React.FC = ({ + groupBySelection, + filters, + query, + signalIndexName, + runtimeMappings, +}: Props) => { + const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_COLLAPSED_CHART_ID}-${uuid()}`, []); + const aggregations = useMemo(() => combinedAggregations(groupBySelection), [groupBySelection]); + + const { items, isLoading } = useSummaryChartData({ + aggregations, + filters, + query, + signalIndexName, + runtimeMappings, + uniqueQueryId, + }); + const data = useMemo(() => (getIsChartCollapseData(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 ( + + {!isLoading && ( + + + + {severities.map((severity) => ( + + + + {`${severity.label}: `} + + + + + ))} + + + + + {i18n.TOP_RULE_TITLE} + {topRule} + + + + + + {`${i18n.TOP_GROUP_TITLE} ${groupBy}: `} + {topGroup} + + + + + + + + )} + + ); +}; + +ChartCollapse.displayName = 'ChartCollapse'; 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 0000000000000..0b7ba68c99ec6 --- /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 0000000000000..adc53e8859f39 --- /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 new file mode 100644 index 0000000000000..1dfa7ea316372 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_collapse/types.ts @@ -0,0 +1,34 @@ +/* + * 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 type { BucketItem } from '../../../../../../common/search_strategy/security_solution/cti'; +import type { + SeverityBucket, + SeverityBuckets as SeverityData, +} from '../../../../../overview/components/detection_response/alerts_by_status/types'; + +export interface ChartCollapseAgg { + severities: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: SeverityBucket[]; + }; + topRule: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; + }; + topGrouping: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; + }; +} +export interface ChartCollapseData { + 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 fe4cf26fd2c7d..60f55d79c895e 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 @@ -9,6 +9,7 @@ import type { AlertViewSelection } from './helpers'; import { getButtonProperties, getContextMenuPanels, + getOptionProperties, TABLE_ID, TREEMAP_ID, TREND_ID, @@ -99,4 +100,42 @@ describe('helpers', () => { }); }); }); + + describe('getOptionProperties', () => { + test('it returns the expected properties when alertViewSelection is Trend', () => { + expect(getOptionProperties(TREND_ID)).toEqual({ + 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': `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': `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': `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 9166e73331413..24d85b251e1cb 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 @@ -5,7 +5,7 @@ * 2.0. */ -import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import type { EuiContextMenuPanelDescriptor, EuiButtonGroupOptionProps } from '@elastic/eui'; import * as i18n from './translations'; @@ -92,3 +92,42 @@ export const getContextMenuPanels = ({ ], }, ]; + +export const getOptionProperties = ( + alertViewSelection: AlertViewSelection +): EuiButtonGroupOptionProps => { + 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': `chart-select-${TABLE_ID}`, + label: i18n.TABLE_TITLE, + value: TABLE_ID, + }; + case TREND_ID: + return { + id: TREND_ID, + 'data-test-subj': `chart-select-${TREND_ID}`, + label: i18n.TREND_TITLE, + value: TREND_ID, + }; + case TREEMAP_ID: + return { + id: TREEMAP_ID, + 'data-test-subj': `chart-select-${TREEMAP_ID}`, + label: i18n.TREEMAP_TITLE, + value: TREEMAP_ID, + }; + case CHARTS_ID: + return charts; + default: + return charts; + } +}; 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 a666a64f7941e..a5110a96a56e9 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 307dfb24f0600..c31e04afeb301 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 @@ -6,12 +6,12 @@ */ import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; -import { EuiButton, EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui'; +import { EuiButton, EuiContextMenu, EuiIcon, EuiPopover, EuiButtonGroup } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; import type { AlertViewSelection } from './helpers'; -import { getButtonProperties, getContextMenuPanels } from './helpers'; +import { getButtonProperties, getContextMenuPanels, getOptionProperties } from './helpers'; import * as i18n from './translations'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; interface Props { @@ -22,6 +22,7 @@ interface Props { const ChartTypeIcon = styled(EuiIcon)` margin-right: ${({ theme }) => theme.eui.euiSizeS}; `; +const AlertViewOptions: AlertViewSelection[] = ['charts', 'trend', 'table', 'treemap']; const ChartSelectComponent: React.FC = ({ alertViewSelection, @@ -50,6 +51,10 @@ const ChartSelectComponent: React.FC = ({ ); }, [alertViewSelection, onButtonClick]); const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); + const options = useMemo(() => { + return AlertViewOptions.map((option: AlertViewSelection) => getOptionProperties(option)); + }, []); + const panels: EuiContextMenuPanelDescriptor[] = useMemo( () => getContextMenuPanels({ @@ -62,15 +67,30 @@ const ChartSelectComponent: React.FC = ({ ); return ( - - - + <> + {isAlertsPageChartsEnabled ? ( + setAlertViewSelection(id as AlertViewSelection)} + buttonSize="compressed" + color="primary" + data-test-subj="chart-select-tabs" + /> + ) : ( + + + + )} + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts index a170be5b29720..fb5bcb630eba6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts @@ -32,3 +32,38 @@ export const TREEMAP = i18n.translate( export const CHARTS = i18n.translate('xpack.securitySolution.components.chartSelect.chartsOption', { defaultMessage: 'Charts', }); + +export const TABLE_TITLE = i18n.translate( + 'xpack.securitySolution.components.chartSelect.tableOptionTitle', + { + defaultMessage: 'Aggregations', + } +); + +export const TREND_TITLE = i18n.translate( + 'xpack.securitySolution.components.chartSelect.trendOptionTitle', + { + defaultMessage: 'Trend Analysis', + } +); + +export const TREEMAP_TITLE = i18n.translate( + 'xpack.securitySolution.components.chartSelect.treemapOptionTitle', + { + defaultMessage: 'Multi-dimensional', + } +); + +export const CHARTS_TITLE = i18n.translate( + 'xpack.securitySolution.components.chartSelect.chartsOptionTitle', + { + defaultMessage: 'Summary', + } +); + +export const LEGEND_TITLE = i18n.translate( + 'xpack.securitySolution.components.chartSelect.legendTitle', + { + defaultMessage: 'Select a tab', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.test.tsx index f87b1b3fcbbf7..d777ddce738b8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.test.tsx @@ -16,6 +16,8 @@ import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; import { TestProviders } from '../../../../common/mock'; import { ChartPanels } from '.'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; jest.mock('./alerts_local_storage'); jest.mock('../../../../common/containers/sourcerer'); @@ -59,16 +61,25 @@ jest.mock('../../../../common/lib/kibana', () => { }; }); +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 1ee5bbf7cfe9b..82e10d884d0e2 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,7 +8,7 @@ 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 { EuiFlexItem, EuiLoadingContent } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; @@ -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'; @@ -30,11 +31,12 @@ import { import { AlertsCountPanel } from '../../../components/alerts_kpis/alerts_count_panel'; import { GROUP_BY_LABEL } from '../../../components/alerts_kpis/common/translations'; import { RESET_GROUP_BY_FIELDS } from '../../../../common/components/chart_settings_popover/configurations/default/translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; -const TABLE_PANEL_HEIGHT = 330; // px -const TRENT_CHART_HEIGHT = 127; // px -const TREND_CHART_PANEL_HEIGHT = 256; // px -const ALERTS_CHARTS_PANEL_HEIGHT = 375; // px +const TREND_CHART_HEIGHT = 280; // px +const CHART_PANEL_HEIGHT = 375; // px + +const DETECTIONS_ALERTS_CHARTS_PANEL_ID = 'detection-alerts-charts-panel'; const FullHeightFlexItem = styled(EuiFlexItem)` height: 100%; @@ -46,7 +48,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; @@ -56,23 +58,30 @@ export interface Props { const ChartPanelsComponent: React.FC = ({ addFilter, - alertsHistogramDefaultFilters, + alertsDefaultFilters, isLoadingIndexPattern, query, runtimeMappings, signalIndexName, updateDateRangeCallback, }: Props) => { + const { toggleStatus: isExpanded, setToggleStatus: setIsExpanded } = useQueryToggle( + DETECTIONS_ALERTS_CHARTS_PANEL_ID + ); + const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); + const { alertViewSelection, countTableStackBy0, countTableStackBy1, + groupBySelection, isTreemapPanelExpanded, riskChartStackBy0, riskChartStackBy1, setAlertViewSelection, setCountTableStackBy0, setCountTableStackBy1, + setGroupBySelection, setIsTreemapPanelExpanded, setRiskChartStackBy0, setRiskChartStackBy1, @@ -157,18 +166,45 @@ const ChartPanelsComponent: React.FC = ({ [onReset, updateCommonStackBy0, updateCommonStackBy1] ); - const title = useMemo( - () => ( - - { + if (isAlertsPageChartsEnabled) { + return isExpanded ? ( + + + + ) : ( + - - ), - [alertViewSelection, setAlertViewSelection] - ); - const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); + ); + } else { + return ( + + + + ); + } + }, [ + alertViewSelection, + setAlertViewSelection, + isAlertsPageChartsEnabled, + isExpanded, + groupBySelection, + alertsDefaultFilters, + query, + signalIndexName, + runtimeMappings, + ]); return (
@@ -179,15 +215,15 @@ const ChartPanelsComponent: React.FC = ({ ) : ( = ({ title={title} titleSize={'s'} updateDateRange={updateDateRangeCallback} + isExpanded={isExpanded} + setIsExpanded={setIsExpanded} /> )} @@ -213,9 +251,9 @@ const ChartPanelsComponent: React.FC = ({ alignHeader="flexStart" chartOptionsContextMenu={chartOptionsContextMenu} extraActions={resetGroupByFieldAction} - filters={alertsHistogramDefaultFilters} + filters={alertsDefaultFilters} inspectTitle={i18n.TABLE} - panelHeight={TABLE_PANEL_HEIGHT} + panelHeight={CHART_PANEL_HEIGHT} query={query} runtimeMappings={runtimeMappings} setStackByField0={updateCommonStackBy0} @@ -228,6 +266,8 @@ const ChartPanelsComponent: React.FC = ({ stackByField1={countTableStackBy1} stackByField1ComboboxRef={stackByField1ComboboxRef} title={title} + isExpanded={isExpanded} + setIsExpanded={setIsExpanded} /> )} @@ -242,13 +282,15 @@ const ChartPanelsComponent: React.FC = ({ addFilter={addFilter} alignHeader="flexStart" chartOptionsContextMenu={chartOptionsContextMenu} - filters={alertsHistogramDefaultFilters} + height={CHART_PANEL_HEIGHT} inspectTitle={i18n.TREEMAP} - isPanelExpanded={isTreemapPanelExpanded} + isPanelExpanded={isAlertsPageChartsEnabled ? isExpanded : isTreemapPanelExpanded} + filters={alertsDefaultFilters} query={query} riskSubAggregationField="kibana.alert.risk_score" - runtimeMappings={runtimeMappings} - setIsPanelExpanded={setIsTreemapPanelExpanded} + setIsPanelExpanded={ + isAlertsPageChartsEnabled ? setIsExpanded : setIsTreemapPanelExpanded + } setStackByField0={updateCommonStackBy0} setStackByField0ComboboxInputRef={setStackByField0ComboboxInputRef} setStackByField1={updateCommonStackBy1} @@ -267,17 +309,21 @@ const ChartPanelsComponent: React.FC = ({ {isAlertsPageChartsEnabled && alertViewSelection === 'charts' && ( {isLoadingIndexPattern ? ( - + ) : ( )} 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 dfb4b0c299d0c..de109fbd691d1 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 = ({