diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx index 561126f3264ad..4fce0361d5d13 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx @@ -18,6 +18,11 @@ import { AlertsCountAggregation } from './types'; jest.mock('../../../../common/lib/kibana'); const mockDispatch = jest.fn(); +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + jest.mock('react-redux', () => { const original = jest.requireActual('react-redux'); return { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx index bd747fb637cb8..cd407a125cdb6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx @@ -6,10 +6,9 @@ */ import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../common/constants'; -import type { AlertsStackByField } from '../common/types'; export const getAlertsCountQuery = ( - stackByField: AlertsStackByField, + stackByField: string, from: string, to: string, additionalFilters: Array<{ 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 9660916d4f32c..d0b05587a4711 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 @@ -13,6 +13,11 @@ import { TestProviders } from '../../../../common/mock'; import { AlertsCountPanel } from './index'; +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + describe('AlertsCountPanel', () => { const defaultProps = { signalIndexName: 'signalIndexName', 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 94b09c4a5ea21..b69f4f1f498f9 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 @@ -21,8 +21,7 @@ import * as i18n from './translations'; import { AlertsCount } from './alerts_count'; import type { AlertsCountAggregation } from './types'; import { DEFAULT_STACK_BY_FIELD } from '../common/config'; -import type { AlertsStackByField } from '../common/types'; -import { KpiPanel, StackBySelect } from '../common/components'; +import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count'; @@ -39,8 +38,7 @@ export const AlertsCountPanel = memo( // create a unique, but stable (across re-renders) query id const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_COUNT_ID}-${uuid.v4()}`, []); - const [selectedStackByOption, setSelectedStackByOption] = - useState(DEFAULT_STACK_BY_FIELD); + const [selectedStackByOption, setSelectedStackByOption] = useState(DEFAULT_STACK_BY_FIELD); // TODO: Once we are past experimental phase this code should be removed // const fetchMethod = useIsExperimentalFeatureEnabled('ruleRegistryEnabled') @@ -99,7 +97,7 @@ export const AlertsCountPanel = memo( titleSize="s" hideSubtitle > - + { ...originalModule, createHref: jest.fn(), useHistory: jest.fn(), + useLocation: jest.fn().mockReturnValue({ pathname: '' }), }; }); @@ -37,9 +38,21 @@ jest.mock('../../../../common/lib/kibana/kibana_react', () => { navigateToApp: mockNavigateToApp, getUrlForApp: jest.fn(), }, + data: { + search: { + search: jest.fn(), + }, + }, uiSettings: { get: jest.fn(), }, + notifications: { + toasts: { + addWarning: jest.fn(), + addError: jest.fn(), + addSuccess: jest.fn(), + }, + }, }, }), }; 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 873b5d40184ef..11dbb4da863db 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 @@ -41,7 +41,7 @@ import { LinkButton } from '../../../../common/components/links'; import { SecurityPageName } from '../../../../app/types'; import { DEFAULT_STACK_BY_FIELD, PANEL_HEIGHT } from '../common/config'; import type { AlertsStackByField } from '../common/types'; -import { KpiPanel, StackBySelect } from '../common/components'; +import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; @@ -109,7 +109,7 @@ export const AlertsHistogramPanel = memo( const [isInspectDisabled, setIsInspectDisabled] = useState(false); const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); const [totalAlertsObj, setTotalAlertsObj] = useState(defaultTotalAlertsObj); - const [selectedStackByOption, setSelectedStackByOption] = useState( + const [selectedStackByOption, setSelectedStackByOption] = useState( onlyField == null ? defaultStackByOption : onlyField ); @@ -276,10 +276,12 @@ export const AlertsHistogramPanel = memo( {showStackBy && ( - + <> + + )} {headerChildren != null && headerChildren} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx index 53d41835d6bb9..6a56f7bc220ac 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import { EuiPanel, EuiSelect } from '@elastic/eui'; +import { EuiPanel, EuiComboBox } from '@elastic/eui'; import styled from 'styled-components'; -import React, { useCallback } from 'react'; -import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT, alertsStackByOptions } from './config'; -import type { AlertsStackByField } from './types'; +import React, { useCallback, useMemo } from 'react'; +import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT } from './config'; +import { useStackByFields } from './hooks'; import * as i18n from './translations'; export const KpiPanel = styled(EuiPanel)<{ height?: number }>` @@ -25,24 +25,45 @@ export const KpiPanel = styled(EuiPanel)<{ height?: number }>` } `; interface StackedBySelectProps { - selected: AlertsStackByField; - onSelect: (selected: AlertsStackByField) => void; + selected: string; + onSelect: (selected: string) => void; } -export const StackBySelect: React.FC = ({ selected, onSelect }) => { - const setSelectedOptionCallback = useCallback( - (event: React.ChangeEvent) => { - onSelect(event.target.value as AlertsStackByField); +export const StackByComboBoxWrapper = styled.div` + width: 400px; +`; + +export const StackByComboBox: React.FC = ({ selected, onSelect }) => { + const onChange = useCallback( + (options) => { + if (options && options.length > 0) { + onSelect(options[0].value); + } else { + onSelect(''); + } }, [onSelect] ); - + const selectedOptions = useMemo(() => { + return [{ label: selected, value: selected }]; + }, [selected]); + const stackOptions = useStackByFields(); + const singleSelection = useMemo(() => { + return { asPlainText: true }; + }, []); return ( - + + + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx index ad0fc1fa7ac61..d68c5c303cfd7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.test.tsx @@ -5,8 +5,16 @@ * 2.0. */ +import React from 'react'; import { renderHook } from '@testing-library/react-hooks'; -import { useInspectButton, UseInspectButtonParams } from './hooks'; +import { useInspectButton, UseInspectButtonParams, useStackByFields } from './hooks'; +import { mockBrowserFields } from '../../../../common/containers/source/mock'; +import { TestProviders } from '../../../../common/mock'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); describe('hooks', () => { describe('useInspectButton', () => { @@ -43,4 +51,22 @@ describe('hooks', () => { expect(mockDeleteQuery).toHaveBeenCalledWith({ id: defaultParams.uniqueQueryId }); }); }); + + describe('useStackByFields', () => { + jest.mock('../../../../common/containers/sourcerer', () => ({ + useSourcererDataView: jest.fn().mockReturnValue({ browserFields: mockBrowserFields }), + })); + it('returns only aggregateable fields', () => { + const wrapper = ({ children }: { children: JSX.Element }) => ( + {children} + ); + const { result, unmount } = renderHook(() => useStackByFields(), { wrapper }); + const aggregateableFields = result.current; + unmount(); + expect(aggregateableFields?.find((field) => field.label === 'agent.id')).toBeTruthy(); + expect( + aggregateableFields?.find((field) => field.label === 'nestedField.firstAttributes') + ).toBe(undefined); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.ts index 6375e2b0c27fb..65b87670810b0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/hooks.ts @@ -5,8 +5,13 @@ * 2.0. */ -import { useEffect } from 'react'; +import { useEffect, useState, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import type { BrowserField } from '../../../../../../timelines/common'; import type { GlobalTimeArgs } from '../../../../common/containers/use_global_time'; +import { getScopeFromPath, useSourcererDataView } from '../../../../common/containers/sourcerer'; +import { getAllFieldsByName } from '../../../../common/containers/source'; export interface UseInspectButtonParams extends Pick { response: string; @@ -15,6 +20,7 @@ export interface UseInspectButtonParams extends Pick }) { + return Object.entries(fields).reduce( + (filteredOptions: EuiComboBoxOptionOption[], [key, field]) => { + if (field.aggregatable === true) { + return [...filteredOptions, { label: key, value: key }]; + } else { + return filteredOptions; + } + }, + [] + ); +} + +export const useStackByFields = () => { + const { pathname } = useLocation(); + + const { browserFields } = useSourcererDataView(getScopeFromPath(pathname)); + const allFields = useMemo(() => getAllFieldsByName(browserFields), [browserFields]); + const [stackByFieldOptions, setStackByFieldOptions] = useState(() => + getAggregatableFields(allFields) + ); + useEffect(() => { + setStackByFieldOptions(getAggregatableFields(allFields)); + }, [allFields]); + return useMemo(() => stackByFieldOptions, [stackByFieldOptions]); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts index d99e1d4744ae7..d45563675154e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/translations.ts @@ -13,3 +13,17 @@ export const STACK_BY_LABEL = i18n.translate( defaultMessage: 'Stack by', } ); + +export const STACK_BY_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.histogram.stackByOptions.stackByPlaceholder', + { + defaultMessage: 'Select a field to stack by', + } +); + +export const STACK_BY_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.histogram.stackByOptions.stackByAriaLabel', + { + defaultMessage: 'Stack the alerts histogram by a field value', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index eeb22a2aa071c..c5d053c57fc97 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -23,6 +23,7 @@ import { useSourcererDataView } from '../../../common/containers/sourcerer'; import { createStore, State } from '../../../common/store'; import { mockHistory, Router } from '../../../common/mock/router'; import { mockTimelines } from '../../../common/mock/mock_timelines_plugin'; +import { mockBrowserFields } from '../../../common/containers/source/mock'; // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar @@ -71,6 +72,9 @@ jest.mock('../../../common/lib/kibana', () => { siem: { crud_alerts: true, read_alerts: true }, }, }, + uiSettings: { + get: jest.fn(), + }, timelines: { ...mockTimelines }, data: { query: { @@ -113,6 +117,7 @@ describe('DetectionEnginePageComponent', () => { (useSourcererDataView as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, + browserFields: mockBrowserFields, }); });