diff --git a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx index 1db522f4c011e..dd5cbb4131b4c 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx @@ -15,6 +15,7 @@ import { EventsQueryTabBody, ALERTS_EVENTS_HISTOGRAM_ID } from './events_query_t import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { licenseService } from '../../hooks/use_license'; import { mockHistory } from '../../mock/router'; +import { DEFAULT_EVENTS_STACK_BY_VALUE } from './histogram_configurations'; const mockGetDefaultControlColumn = jest.fn(); jest.mock('../../../timelines/components/timeline/body/control_columns', () => ({ @@ -144,7 +145,7 @@ describe('EventsQueryTabBody', () => { ); expect(result.getByTestId('header-section-supplements').querySelector('select')?.value).toEqual( - 'event.action' + DEFAULT_EVENTS_STACK_BY_VALUE ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_tab/histogram_configurations.ts b/x-pack/plugins/security_solution/public/common/components/events_tab/histogram_configurations.ts index 12458d5c52a5f..ecb3400620b28 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_tab/histogram_configurations.ts +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/histogram_configurations.ts @@ -11,7 +11,9 @@ import { getEventsHistogramLensAttributes } from '../visualization_actions/lens_ import type { MatrixHistogramConfigs, MatrixHistogramOption } from '../matrix_histogram/types'; import * as i18n from './translations'; -const DEFAULT_EVENTS_STACK_BY = 'event.action'; +export const NO_BREAKDOWN_STACK_BY_VALUE = 'no_breakdown'; + +export const DEFAULT_EVENTS_STACK_BY_VALUE = NO_BREAKDOWN_STACK_BY_VALUE; export const getSubtitleFunction = (defaultNumberFormat: string, isAlert: boolean) => (totalCount: number) => @@ -20,6 +22,10 @@ export const getSubtitleFunction = }`; export const eventsStackByOptions: MatrixHistogramOption[] = [ + { + text: i18n.EVENTS_GRAPH_NO_BREAKDOWN_TITLE, + value: NO_BREAKDOWN_STACK_BY_VALUE, + }, { text: 'event.action', value: 'event.action', @@ -36,7 +42,8 @@ export const eventsStackByOptions: MatrixHistogramOption[] = [ export const eventsHistogramConfig: MatrixHistogramConfigs = { defaultStackByOption: - eventsStackByOptions.find((o) => o.text === DEFAULT_EVENTS_STACK_BY) ?? eventsStackByOptions[0], + eventsStackByOptions.find((o) => o.value === DEFAULT_EVENTS_STACK_BY_VALUE) ?? + eventsStackByOptions[0], stackByOptions: eventsStackByOptions, subtitle: undefined, title: i18n.EVENTS_GRAPH_TITLE, @@ -58,7 +65,7 @@ const DEFAULT_STACK_BY = 'event.module'; export const alertsHistogramConfig: MatrixHistogramConfigs = { defaultStackByOption: - alertsStackByOptions.find((o) => o.text === DEFAULT_STACK_BY) ?? alertsStackByOptions[0], + alertsStackByOptions.find((o) => o.value === DEFAULT_STACK_BY) ?? alertsStackByOptions[0], stackByOptions: alertsStackByOptions, title: i18n.ALERTS_GRAPH_TITLE, getLensAttributes: getExternalAlertLensAttributes, diff --git a/x-pack/plugins/security_solution/public/common/components/events_tab/translations.ts b/x-pack/plugins/security_solution/public/common/components/events_tab/translations.ts index 95300554df00c..8bfe235682a60 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_tab/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/translations.ts @@ -54,3 +54,10 @@ export const SHOW_EXTERNAL_ALERTS = i18n.translate( export const EVENTS_GRAPH_TITLE = i18n.translate('xpack.securitySolution.eventsGraphTitle', { defaultMessage: 'Events', }); + +export const EVENTS_GRAPH_NO_BREAKDOWN_TITLE = i18n.translate( + 'xpack.securitySolution.eventsHistogram.selectOptions.noBreakDownLabel', + { + defaultMessage: 'No breakdown', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index 4c4b1f3e29e8a..bcc98f089cf62 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -28,6 +28,7 @@ import { VISUALIZATION_ACTIONS_BUTTON_CLASS } from '../visualization_actions/uti import { VisualizationEmbeddable } from '../visualization_actions/visualization_embeddable'; import { useVisualizationResponse } from '../visualization_actions/use_visualization_response'; import type { SourcererScopeName } from '../../../sourcerer/store/model'; +import { NO_BREAKDOWN_STACK_BY_VALUE } from '../events_tab/histogram_configurations'; export type MatrixHistogramComponentProps = MatrixHistogramQueryProps & MatrixHistogramConfigs & { @@ -165,6 +166,13 @@ export const MatrixHistogramComponent: React.FC = [isPtrIncluded, filterQuery] ); + // If the user selected the `No breakdown` option, we shouldn't perform the aggregation + const stackByField = useMemo(() => { + return selectedStackByOption.value === NO_BREAKDOWN_STACK_BY_VALUE + ? undefined + : selectedStackByOption.value; + }, [selectedStackByOption.value]); + if (hideHistogram) { return null; } @@ -216,7 +224,7 @@ export const MatrixHistogramComponent: React.FC = id={visualizationId} inspectTitle={title as string} lensAttributes={lensAttributes} - stackByField={selectedStackByOption.value} + stackByField={stackByField} timerange={timerange} /> ) : null} diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts index 032a768df355e..72b2ae4184a40 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts @@ -12,7 +12,7 @@ import type { GetLensAttributes, LensAttributes } from '../visualization_actions export interface MatrixHistogramOption { text: string; - value: string; + value: string | undefined; } export type GetSubTitle = (count: number) => string; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/event.test.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/event.test.ts index f316f881ba60e..a7855ff7367bd 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/event.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/event.test.ts @@ -11,7 +11,7 @@ import { wrapper } from '../../mocks'; import { useLensAttributes } from '../../use_lens_attributes'; -import { getEventsHistogramLensAttributes } from './events'; +import { getEventsHistogramLensAttributes, stackByFieldAccessorId } from './events'; jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('0039eb0c-9a1a-4687-ae54-0f4e239bec75'), @@ -497,4 +497,41 @@ describe('getEventsHistogramLensAttributes', () => { }) ); }); + + it('should render the layer for the stackByField when provided', () => { + const { result } = renderHook( + () => + useLensAttributes({ + getLensAttributes: getEventsHistogramLensAttributes, + stackByField: 'event.dataset', + }), + { wrapper } + ); + + expect(result?.current?.state?.visualization).toEqual( + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ splitAccessor: stackByFieldAccessorId }), + ]), + }) + ); + }); + + it('should not render the layer for the stackByField is undefined', () => { + const { result } = renderHook( + () => + useLensAttributes({ + getLensAttributes: getEventsHistogramLensAttributes, + }), + { wrapper } + ); + + expect(result?.current?.state?.visualization).toEqual( + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.not.objectContaining({ splitAccessor: stackByFieldAccessorId }), + ]), + }) + ); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/events.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/events.ts index e834fdd986061..174abce6baf62 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/events.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/events.ts @@ -8,9 +8,11 @@ import { v4 as uuidv4 } from 'uuid'; import type { GetLensAttributes } from '../../types'; const layerId = uuidv4(); +// Exported for testing purposes +export const stackByFieldAccessorId = '34919782-4546-43a5-b668-06ac934d3acd'; export const getEventsHistogramLensAttributes: GetLensAttributes = ( - stackByField = 'event.action', + stackByField, extraOptions = {} ) => { return { @@ -37,7 +39,7 @@ export const getEventsHistogramLensAttributes: GetLensAttributes = ( showGridlines: false, layerType: 'data', xAccessor: 'aac9d7d0-13a3-480a-892b-08207a787926', - splitAccessor: '34919782-4546-43a5-b668-06ac934d3acd', + splitAccessor: stackByField ? stackByFieldAccessorId : undefined, }, ], yRightExtent: { @@ -83,30 +85,32 @@ export const getEventsHistogramLensAttributes: GetLensAttributes = ( sourceField: '___records___', params: { emptyAsNull: true }, }, - '34919782-4546-43a5-b668-06ac934d3acd': { - label: `Top values of ${stackByField}`, - dataType: 'string', - operationType: 'terms', - scale: 'ordinal', - sourceField: `${stackByField}`, - isBucketed: true, - params: { - size: 10, - orderBy: { - type: 'column', - columnId: 'e09e0380-0740-4105-becc-0a4ca12e3944', - }, - orderDirection: 'desc', - otherBucket: true, - missingBucket: false, - parentFormat: { - id: 'terms', + ...(stackByField && { + [stackByFieldAccessorId]: { + label: `Top values of ${stackByField}`, + dataType: 'string', + operationType: 'terms', + scale: 'ordinal', + sourceField: `${stackByField}`, + isBucketed: true, + params: { + size: 10, + orderBy: { + type: 'column', + columnId: 'e09e0380-0740-4105-becc-0a4ca12e3944', + }, + orderDirection: 'desc', + otherBucket: true, + missingBucket: false, + parentFormat: { + id: 'terms', + }, }, }, - }, + }), }, columnOrder: [ - '34919782-4546-43a5-b668-06ac934d3acd', + ...(stackByField ? [stackByFieldAccessorId] : []), 'aac9d7d0-13a3-480a-892b-08207a787926', 'e09e0380-0740-4105-becc-0a4ca12e3944', ], diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx index c7554abafafc6..22fa8c774eebe 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx @@ -22,6 +22,7 @@ import { useSourcererDataView } from '../../../sourcerer/containers'; import { kpiHostMetricLensAttributes } from './lens_attributes/hosts/kpi_host_metric'; import { useRouteSpy } from '../../utils/route/use_route_spy'; import { SecurityPageName } from '../../../app/types'; +import { getEventsHistogramLensAttributes } from './lens_attributes/common/events'; jest.mock('../../../sourcerer/containers'); jest.mock('../../utils/route/use_route_spy', () => ({ @@ -212,6 +213,25 @@ describe('useLensAttributes', () => { ]); }); + it('should not set splitAccessor if stackByField is undefined', () => { + const { result } = renderHook( + () => + useLensAttributes({ + getLensAttributes: getEventsHistogramLensAttributes, + stackByField: undefined, + }), + { wrapper } + ); + + expect(result?.current?.state?.visualization).toEqual( + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ seriesType: 'bar_stacked', splitAccessor: undefined }), + ]), + }) + ); + }); + it('should return null if no indices exist', () => { (useSourcererDataView as jest.Mock).mockReturnValue({ dataViewId: 'security-solution-default', diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx index 1c6b37d4d7dd9..6a227c84681b1 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx @@ -72,7 +72,7 @@ export const useLensAttributes = ({ () => lensAttributes ?? ((getLensAttributes && - stackByField && + stackByField !== null && getLensAttributes(stackByField, extraOptions)) as LensAttributes), [extraOptions, getLensAttributes, lensAttributes, stackByField] ); @@ -82,7 +82,7 @@ export const useLensAttributes = ({ const lensAttrsWithInjectedData = useMemo(() => { if ( lensAttributes == null && - (getLensAttributes == null || stackByField == null || stackByField?.length === 0) + (getLensAttributes == null || stackByField === null || stackByField?.length === 0) ) { return null; } diff --git a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx index 37051c2b5c822..eb551d4ba20aa 100644 --- a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx @@ -26,6 +26,7 @@ import { useKibana, useUiSetting$ } from '../../../common/lib/kibana'; import { eventsStackByOptions, eventsHistogramConfig, + NO_BREAKDOWN_STACK_BY_VALUE, } from '../../../common/components/events_tab/histogram_configurations'; import { HostsTableType } from '../../../explore/hosts/store/model'; import type { GlobalTimeArgs } from '../../../common/containers/use_global_time'; @@ -36,7 +37,7 @@ import { useFormatUrl } from '../../../common/components/link_to'; import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; import type { SourcererScopeName } from '../../../sourcerer/store/model'; -const DEFAULT_STACK_BY = 'event.dataset'; +const DEFAULT_STACK_BY = NO_BREAKDOWN_STACK_BY_VALUE; const ID = 'eventsByDatasetOverview'; const CHART_HEIGHT = 160; @@ -156,7 +157,7 @@ const EventsByDatasetComponent: React.FC = ({ defaultStackByOption: onlyField != null ? getHistogramOption(onlyField) - : eventsStackByOptions.find((o) => o.text === DEFAULT_STACK_BY) ?? + : eventsStackByOptions.find((o) => o.value === DEFAULT_STACK_BY) ?? eventsStackByOptions[0], legendPosition: Position.Right, subtitle: (totalCount: number) => diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx index f70f4e1f261f2..7e5e9a221ffee 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx @@ -100,8 +100,11 @@ const TestComponent = (props: Partial>) = const dispatch = useDispatch(); - // populating timeline so that it is not blank useEffect(() => { + // Unified field list can be a culprit for long load times, so we wait for the timeline to be interacted with to load + dispatch(timelineActions.showTimeline({ id: TimelineId.test, show: true })); + + // populating timeline so that it is not blank dispatch( timelineActions.applyKqlFilterQuery({ id: TimelineId.test, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx index c50c2877e2fe1..93524ac50e245 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx @@ -92,7 +92,10 @@ const SPECIAL_TEST_TIMEOUT = 50000; const localMockedTimelineData = structuredClone(mockTimelineData); -const TestComponent = (props: Partial>) => { +const TestComponent = ( + props: Partial> & { show?: boolean } +) => { + const { show, ...restProps } = props; const testComponentDefaultProps: ComponentProps = { columns: getColumnHeaders(columnsToDisplay, mockSourcererScope.browserFields), activeTab: TimelineTabs.query, @@ -119,8 +122,11 @@ const TestComponent = (props: Partial>) = const dispatch = useDispatch(); - // populating timeline so that it is not blank useEffect(() => { + // Unified field list can be a culprit for long load times, so we wait for the timeline to be interacted with to load + dispatch(timelineActions.showTimeline({ id: TimelineId.test, show: show ?? true })); + + // populating timeline so that it is not blank dispatch( timelineActions.applyKqlFilterQuery({ id: TimelineId.test, @@ -133,9 +139,9 @@ const TestComponent = (props: Partial>) = }, }) ); - }, [dispatch]); + }, [dispatch, show]); - return ; + return ; }; const customStore = createMockStore(); @@ -513,6 +519,51 @@ describe('unified timeline', () => { }); describe('unified field list', () => { + describe('render', () => { + let TestProviderWithNewStore: FC>; + beforeEach(() => { + const freshStore = createMockStore(); + // eslint-disable-next-line react/display-name + TestProviderWithNewStore = ({ children }) => { + return {children}; + }; + }); + it( + 'should not render when timeline has never been opened', + async () => { + render(, { + wrapper: TestProviderWithNewStore, + }); + expect(await screen.queryByTestId('timeline-sidebar')).not.toBeInTheDocument(); + }, + SPECIAL_TEST_TIMEOUT + ); + + it( + 'should render when timeline has been opened', + async () => { + render(, { + wrapper: TestProviderWithNewStore, + }); + expect(await screen.queryByTestId('timeline-sidebar')).toBeInTheDocument(); + }, + SPECIAL_TEST_TIMEOUT + ); + + it( + 'should not re-render when timeline has been opened at least once', + async () => { + const { rerender } = render(, { + wrapper: TestProviderWithNewStore, + }); + rerender(); + // Even after timeline is closed, it should still exist in the background + expect(await screen.queryByTestId('timeline-sidebar')).toBeInTheDocument(); + }, + SPECIAL_TEST_TIMEOUT + ); + }); + it( 'should be able to add filters', async () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx index 19a4c0bef70de..7d89da9002ba8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx @@ -6,7 +6,7 @@ */ import type { EuiDataGridProps } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiHideFor } from '@elastic/eui'; -import React, { useMemo, useCallback, useState, useRef } from 'react'; +import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { generateFilters } from '@kbn/data-plugin/public'; @@ -26,6 +26,7 @@ import { UnifiedFieldListSidebarContainer } from '@kbn/unified-field-list'; import type { EuiTheme } from '@kbn/react-kibana-context-styled'; import type { CoreStart } from '@kbn/core-lifecycle-browser'; import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { withDataView } from '../../../../common/components/with_data_view'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; import type { TimelineItem } from '../../../../../common/search_strategy'; @@ -47,6 +48,7 @@ import TimelineDataTable from './data_table'; import { timelineActions } from '../../../store'; import { getFieldsListCreationOptions } from './get_fields_list_creation_options'; import { defaultUdtHeaders } from './default_headers'; +import { getTimelineShowStatusByIdSelector } from '../../../store/selectors'; const TimelineBodyContainer = styled.div.attrs(({ className = '' }) => ({ className: `${className}`, @@ -343,6 +345,31 @@ const UnifiedTimelineComponent: React.FC = ({ onFieldEdited(); }, [onFieldEdited]); + // PERFORMANCE ONLY CODE BLOCK + /** + * We check for the timeline open status to request the fields for the fields browser as the fields request + * is often a much longer running request for customers with a significant number of indices and fields in those indices. + * This request should only be made after the user has decided to interact with timeline to prevent any performance impacts + * to the underlying security solution views, as this query will always run when the timeline exists on the page. + * + * `hasTimelineBeenOpenedOnce` - We want to keep timeline loading times as fast as possible after the user + * has chosen to interact with timeline at least once, so we use this flag to prevent re-requesting of this fields data + * every time timeline is closed and re-opened after the first interaction. + */ + + const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []); + const { show } = useDeepEqualSelector((state) => getTimelineShowStatus(state, timelineId)); + + const [hasTimelineBeenOpenedOnce, setHasTimelineBeenOpenedOnce] = useState(false); + + useEffect(() => { + if (!hasTimelineBeenOpenedOnce && show) { + setHasTimelineBeenOpenedOnce(true); + } + }, [hasTimelineBeenOpenedOnce, show]); + + // END PERFORMANCE ONLY CODE BLOCK + return ( = ({ sidebarPanel={ - {dataView ? ( + {dataView && hasTimelineBeenOpenedOnce ? (