diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/cell_actions_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/cell_actions_wrapper.tsx index d10cd5aaf1984..7d20a645abc35 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/cell_actions_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/cell_actions_wrapper.tsx @@ -16,7 +16,7 @@ import { SecurityCellActionType, } from '../cell_actions'; import { getSourcererScopeId } from '../../../helpers'; -import { TimelineContext } from '../../../timelines/components/timeline'; +import { TimelineContext } from '../../../timelines/components/timeline/context'; import { TableContext } from '../events_viewer/shared'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.test.tsx index 5b395979978f3..fd4b00e7c2fe4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.test.tsx @@ -20,6 +20,11 @@ jest.mock('react-redux', () => { }; }); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => jest.fn(), +})); + const mockRef = { current: null, }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx index fa71f93dccb99..9e8453fe08047 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx @@ -15,7 +15,7 @@ import { AddTimelineButton } from './add_timeline_button'; import { timelineActions } from '../../store'; import { TimelineSaveStatus } from '../save_status'; import { AddToFavoritesButton } from '../add_to_favorites'; -import { TimelineEventsCountBadge } from '../../../common/hooks/use_timeline_events_count'; +import TimelineQueryTabEventsCount from '../timeline/tabs/query/events_count'; interface TimelineBottomBarProps { /** @@ -63,9 +63,9 @@ export const TimelineBottomBar = React.memo( {title} - {!show && ( // this is a hack because TimelineEventsCountBadge is using react-reverse-portal so the component which is used in multiple places cannot be visible in multiple places at the same time + {!show && ( // We only want to show this when the timeline modal is closed - + )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/more_container/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/more_container/index.tsx index 8936b77d22fb5..25c5cdcf12be1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/more_container/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/more_container/index.tsx @@ -9,7 +9,7 @@ import React, { useContext, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { css } from '@emotion/css'; import classNames from 'classnames'; -import { TimelineContext } from '../../timeline'; +import { TimelineContext } from '../../timeline/context'; import { getSourcererScopeId } from '../../../../helpers'; import { escapeDataProviderId } from '../../../../common/components/drag_and_drop/helpers'; import { defaultToEmptyTag } from '../../../../common/components/empty_value'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx index 77f26075581e0..7244fe7630d86 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx @@ -14,6 +14,9 @@ import type { UnifiedTimelineBodyProps } from './unified_timeline_body'; import { UnifiedTimelineBody } from './unified_timeline_body'; import { render } from '@testing-library/react'; import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../common/mock'; +import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks'; +import { mockSourcererScope } from '../../../../sourcerer/containers/mocks'; +import { DataView } from '@kbn/data-views-plugin/common'; jest.mock('../unified_components', () => { return { @@ -21,6 +24,18 @@ jest.mock('../unified_components', () => { }; }); +const mockDataView = new DataView({ + spec: mockSourcererScope.sourcererDataView, + fieldFormats: fieldFormatsMock, +}); + +// Not returning an actual dataView here, just an object as a non-null value; +const mockUseGetScopedSourcererDataView = jest.fn().mockImplementation(() => mockDataView); + +jest.mock('../../../../sourcerer/components/use_get_sourcerer_data_view', () => ({ + useGetScopedSourcererDataView: () => mockUseGetScopedSourcererDataView(), +})); + const mockEventsData = structuredClone(mockTimelineData); const defaultProps: UnifiedTimelineBodyProps = { @@ -77,4 +92,11 @@ describe('UnifiedTimelineBody', () => { {} ); }); + + it('should render the dataview error component when no dataView is provided', () => { + mockUseGetScopedSourcererDataView.mockImplementationOnce(() => undefined); + const { queryByTestId } = renderTestComponents(); + + expect(queryByTestId('dataViewErrorComponent')).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx index b705717fc437f..41f9402865ed1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx @@ -8,11 +8,15 @@ import type { ComponentProps, ReactElement } from 'react'; import React, { useMemo } from 'react'; import { RootDragDropProvider } from '@kbn/dom-drag-drop'; +import { useGetScopedSourcererDataView } from '../../../../sourcerer/components/use_get_sourcerer_data_view'; +import { DataViewErrorComponent } from '../../../../common/components/with_data_view/data_view_error'; import { StyledTableFlexGroup, StyledUnifiedTableFlexItem } from '../unified_components/styles'; import { UnifiedTimeline } from '../unified_components'; import { defaultUdtHeaders } from './column_headers/default_headers'; +import { SourcererScopeName } from '../../../../sourcerer/store/model'; -export interface UnifiedTimelineBodyProps extends ComponentProps { +export interface UnifiedTimelineBodyProps + extends Omit, 'dataView'> { header: ReactElement; } @@ -37,7 +41,9 @@ export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => { leadingControlColumns, onUpdatePageIndex, } = props; - + const dataView = useGetScopedSourcererDataView({ + sourcererScope: SourcererScopeName.timeline, + }); const columnsHeader = useMemo(() => columns ?? defaultUdtHeaders, [columns]); return ( @@ -48,26 +54,31 @@ export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => { data-test-subj="unifiedTimelineBody" > - + {dataView ? ( + + ) : ( + + )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/context.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/context.ts new file mode 100644 index 0000000000000..eebd271eec3af --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/context.ts @@ -0,0 +1,12 @@ +/* + * 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 { createContext } from 'react'; + +export const TimelineContext = createContext<{ + timelineId: string | null; +}>({ timelineId: null }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index e54bfc82399f9..0837e05f4f2a9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -7,7 +7,7 @@ import { pick } from 'lodash/fp'; import { EuiProgress } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useRef, createContext } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; @@ -30,6 +30,7 @@ import { EXIT_FULL_SCREEN_CLASS_NAME } from '../../../common/components/exit_ful import { useResolveConflict } from '../../../common/hooks/use_resolve_conflict'; import { sourcererSelectors } from '../../../common/store'; import { defaultUdtHeaders } from './body/column_headers/default_headers'; +import { TimelineContext } from './context'; const TimelineTemplateBadge = styled.div` background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; @@ -44,7 +45,6 @@ const TimelineBody = styled.div` flex-direction: column; `; -export const TimelineContext = createContext<{ timelineId: string | null }>({ timelineId: null }); export interface Props { renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx index 40d4f939b13fd..d7e8615ebb516 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx @@ -44,17 +44,7 @@ import { initializeTimelineSettings } from '../../../store/actions'; import { selectTimelineById, selectTimelineESQLSavedSearchId } from '../../../store/selectors'; import { fetchNotesBySavedObjectIds, selectSortedNotesBySavedObjectId } from '../../../../notes'; import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants'; - -const HideShowContainer = styled.div.attrs<{ $isVisible: boolean; isOverflowYScroll: boolean }>( - ({ $isVisible = false, isOverflowYScroll = false }) => ({ - style: { - display: $isVisible ? 'flex' : 'none', - overflow: isOverflowYScroll ? 'hidden scroll' : 'hidden', - }, - }) -)<{ $isVisible: boolean; isOverflowYScroll?: boolean }>` - flex: 1; -`; +import { LazyTimelineTabRenderer, TimelineTabFallback } from './lazy_timeline_tab_renderer'; /** * A HOC which supplies React.Suspense with a fallback component @@ -75,13 +65,35 @@ const tabWithSuspense =

( return Comp; }; -const QueryTab = tabWithSuspense(lazy(() => import('./query'))); -const EqlTab = tabWithSuspense(lazy(() => import('./eql'))); -const GraphTab = tabWithSuspense(lazy(() => import('./graph'))); -const NotesTab = tabWithSuspense(lazy(() => import('./notes'))); -const PinnedTab = tabWithSuspense(lazy(() => import('./pinned'))); -const SessionTab = tabWithSuspense(lazy(() => import('./session'))); -const EsqlTab = tabWithSuspense(lazy(() => import('./esql'))); +const QueryTab = tabWithSuspense( + lazy(() => import('./query')), + +); +const EqlTab = tabWithSuspense( + lazy(() => import('./eql')), + +); +const GraphTab = tabWithSuspense( + lazy(() => import('./graph')), + +); +const NotesTab = tabWithSuspense( + lazy(() => import('./notes')), + +); +const PinnedTab = tabWithSuspense( + lazy(() => import('./pinned')), + +); +const SessionTab = tabWithSuspense( + lazy(() => import('./session')), + +); +const EsqlTab = tabWithSuspense( + lazy(() => import('./esql')), + +); + interface BasicTimelineTab { renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; @@ -143,60 +155,60 @@ const ActiveTimelineTab = memo( [activeTimelineTab] ); - /* Future developer -> why are we doing that - * It is really expansive to re-render the QueryTab because the drag/drop - * Therefore, we are only hiding its dom when switching to another tab - * to avoid mounting/un-mounting === re-render - */ return ( <> - - + {showTimeline && shouldShowESQLTab && activeTimelineTab === TimelineTabs.esql && ( - - + )} - - + {timelineType === TimelineTypeEnum.default && ( - - + )} - - {isGraphOrNotesTabs && getTab(activeTimelineTab)} - + {isGraphOrNotesTabs ? getTab(activeTimelineTab) : null} + ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/lazy_timeline_tab_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/lazy_timeline_tab_renderer.test.tsx new file mode 100644 index 0000000000000..adda3b2043223 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/lazy_timeline_tab_renderer.test.tsx @@ -0,0 +1,116 @@ +/* + * 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 React, { useEffect } from 'react'; +import { render } from '@testing-library/react'; +import type { LazyTimelineTabRendererProps } from './lazy_timeline_tab_renderer'; +import { LazyTimelineTabRenderer } from './lazy_timeline_tab_renderer'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { TimelineId } from '../../../../../common/types'; + +jest.mock('../../../../common/hooks/use_selector'); + +describe('LazyTimelineTabRenderer', () => { + const mockUseDeepEqualSelector = useDeepEqualSelector as jest.Mock; + const defaultProps = { + dataTestSubj: 'test', + shouldShowTab: true, + isOverflowYScroll: false, + timelineId: TimelineId.test, + }; + + const TestComponent = ({ children, ...restProps }: Partial) => ( + +

{children ?? 'test component'}
+ + ); + const renderTestComponents = (props?: Partial) => { + const { children, ...restProps } = props ?? {}; + return render({children}); + }; + + beforeEach(() => { + mockUseDeepEqualSelector.mockClear(); + }); + + describe('timeline visibility', () => { + it('should NOT render children when the timeline show status is false', () => { + mockUseDeepEqualSelector.mockReturnValue({ show: false }); + const { queryByText } = renderTestComponents(); + expect(queryByText('test component')).not.toBeInTheDocument(); + }); + + it('should render children when the timeline show status is true', () => { + mockUseDeepEqualSelector.mockReturnValue({ show: true }); + + const { getByText } = renderTestComponents(); + + expect(getByText('test component')).toBeInTheDocument(); + }); + }); + + describe('tab visibility', () => { + it('should not render children when show tab is false', () => { + const { queryByText } = renderTestComponents({ shouldShowTab: false }); + + expect(queryByText('test component')).not.toBeInTheDocument(); + }); + }); + + describe('re-rendering', () => { + const testChildString = 'new content'; + const mockFnShouldThatShouldOnlyRunOnce = jest.fn(); + + const TestChild = () => { + useEffect(() => { + mockFnShouldThatShouldOnlyRunOnce(); + }, []); + return
{testChildString}
; + }; + + const RerenderTestComponent = (props?: Partial) => ( + + + + ); + + beforeEach(() => { + jest.resetAllMocks(); + mockUseDeepEqualSelector.mockReturnValue({ show: true }); + }); + + it('should NOT re-render children after the first render', () => { + const { queryByText } = render(); + expect(queryByText(testChildString)).toBeInTheDocument(); + expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(1); + }); + + it('should NOT re-render children even if timeline show status changes', () => { + const { rerender, queryByText } = render(); + mockUseDeepEqualSelector.mockReturnValue({ show: false }); + rerender(); + expect(queryByText(testChildString)).toBeInTheDocument(); + expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(1); + }); + + it('should NOT re-render children even if tab visibility status changes', () => { + const { rerender, queryByText } = render(); + rerender(); + rerender(); + expect(queryByText(testChildString)).toBeInTheDocument(); + expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(1); + }); + + it('should re-render if the component is unmounted and remounted', () => { + const { rerender, queryByText, unmount } = render(); + unmount(); + rerender(); + expect(queryByText(testChildString)).toBeInTheDocument(); + expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/lazy_timeline_tab_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/lazy_timeline_tab_renderer.tsx new file mode 100644 index 0000000000000..31c3bb52b7cdd --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/lazy_timeline_tab_renderer.tsx @@ -0,0 +1,81 @@ +/* + * 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 React, { useMemo, useState, useEffect } from 'react'; +import { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic } from '@elastic/eui'; +import type { TimelineId } from '../../../../../common/types'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { getTimelineShowStatusByIdSelector } from '../../../store/selectors'; + +export interface LazyTimelineTabRendererProps { + children: React.ReactElement | null; + dataTestSubj: string; + isOverflowYScroll?: boolean; + shouldShowTab: boolean; + timelineId: TimelineId; +} + +/** + * We check for the timeline open status to request the fields for the fields browser. 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 a specific tab in the 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. + * + * `hasTimelineTabBeenOpenedOnce` - 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. + */ +export const LazyTimelineTabRenderer = React.memo( + ({ + children, + dataTestSubj, + shouldShowTab, + isOverflowYScroll, + timelineId, + }: LazyTimelineTabRendererProps) => { + const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []); + const { show } = useDeepEqualSelector((state) => getTimelineShowStatus(state, timelineId)); + + const [hasTimelineTabBeenOpenedOnce, setHasTimelineTabBeenOpenedOnce] = useState(false); + + useEffect(() => { + if (!hasTimelineTabBeenOpenedOnce && show && shouldShowTab) { + setHasTimelineTabBeenOpenedOnce(true); + } + }, [hasTimelineTabBeenOpenedOnce, shouldShowTab, show]); + + return ( +
+ {hasTimelineTabBeenOpenedOnce ? children : } +
+ ); + } +); + +LazyTimelineTabRenderer.displayName = 'LazyTimelineTabRenderer'; + +export const TimelineTabFallback = () => ( + + + + + +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/events_count.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/events_count.tsx new file mode 100644 index 0000000000000..89d886108d777 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/events_count.tsx @@ -0,0 +1,228 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { getEsQueryConfig } from '@kbn/data-plugin/common'; +import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { DataLoadingState } from '@kbn/unified-data-table'; +import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; +import { useTimelineDataFilters } from '../../../../containers/use_timeline_data_filters'; +import { useInvalidFilterQuery } from '../../../../../common/hooks/use_invalid_filter_query'; +import { timelineActions, timelineSelectors } from '../../../../store'; +import type { Direction } from '../../../../../../common/search_strategy'; +import { useTimelineEvents } from '../../../../containers'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { combineQueries } from '../../../../../common/lib/kuery'; +import type { + KueryFilterQuery, + KueryFilterQueryKind, +} from '../../../../../../common/types/timeline'; +import type { inputsModel } from '../../../../../common/store'; +import { inputsSelectors } from '../../../../../common/store'; +import { SourcererScopeName } from '../../../../../sourcerer/store/model'; +import { timelineDefaults } from '../../../../store/defaults'; +import { useSourcererDataView } from '../../../../../sourcerer/containers'; +import { isActiveTimeline } from '../../../../../helpers'; +import type { TimelineModel } from '../../../../store/model'; +import { useTimelineColumns } from '../shared/use_timeline_columns'; +import { EventsCountBadge } from '../shared/layout'; + +/** + * TODO: This component is a pared down duplicate of the logic used in timeline/tabs/query/index.tsx + * This is only done to support the events count badge that shows in the bottom bar of the application, + * without needing to render the entire query tab, which is expensive to render at a significant enough fields count. + * The long term solution is a centralized query either via RTK or useQuery, that both can read from, but that is out of scope + * at this current time. + */ + +const emptyFieldsList: string[] = []; +export const TimelineQueryTabEventsCountComponent: React.FC<{ timelineId: string }> = ({ + timelineId, +}) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterKuerySelector(), []); + const getInputsTimeline = useMemo(() => inputsSelectors.getTimelineSelector(), []); + + const timeline: TimelineModel = useDeepEqualSelector( + (state) => getTimeline(state, timelineId) ?? timelineDefaults + ); + const input: inputsModel.InputsRange = useDeepEqualSelector((state) => getInputsTimeline(state)); + const { timerange: { to: end, from: start, kind: timerangeKind } = {} } = input; + const { + columns, + dataProviders, + filters: currentTimelineFilters, + kqlMode, + sort, + timelineType, + } = timeline; + + const kqlQueryTimeline: KueryFilterQuery | null = useDeepEqualSelector((state) => + getKqlQueryTimeline(state, timelineId) + ); + const filters = useMemo( + () => (kqlMode === 'filter' ? currentTimelineFilters || [] : []), + [currentTimelineFilters, kqlMode] + ); + + // return events on empty search + const kqlQueryExpression = + isEmpty(dataProviders) && + isEmpty(kqlQueryTimeline?.expression ?? '') && + timelineType === 'template' + ? ' ' + : kqlQueryTimeline?.expression ?? ''; + + const kqlQueryLanguage = + isEmpty(dataProviders) && timelineType === 'template' + ? 'kuery' + : kqlQueryTimeline?.kind ?? 'kuery'; + + const dispatch = useDispatch(); + const { + browserFields, + dataViewId, + loading: loadingSourcerer, + indexPattern, + // important to get selectedPatterns from useSourcererDataView + // in order to include the exclude filters in the search that are not stored in the timeline + selectedPatterns, + sourcererDataView, + } = useSourcererDataView(SourcererScopeName.timeline); + /* + * `pageIndex` needs to be maintained for each table in each tab independently + * and consequently it cannot be the part of common redux state + * of the timeline. + * + */ + + const { uiSettings, timelineDataService } = useKibana().services; + const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]); + const kqlQuery: { + query: string; + language: KueryFilterQueryKind; + } = useMemo( + () => ({ query: kqlQueryExpression.trim(), language: kqlQueryLanguage }), + [kqlQueryExpression, kqlQueryLanguage] + ); + + const combinedQueries = useMemo(() => { + return combineQueries({ + config: esQueryConfig, + dataProviders, + indexPattern, + browserFields, + filters, + kqlQuery, + kqlMode, + }); + }, [esQueryConfig, dataProviders, indexPattern, browserFields, filters, kqlQuery, kqlMode]); + + useInvalidFilterQuery({ + id: timelineId, + filterQuery: combinedQueries?.filterQuery, + kqlError: combinedQueries?.kqlError, + query: kqlQuery, + startDate: start, + endDate: end, + }); + + const isBlankTimeline: boolean = + isEmpty(dataProviders) && + isEmpty(filters) && + isEmpty(kqlQuery.query) && + combinedQueries?.filterQuery === undefined; + + const canQueryTimeline = useMemo( + () => + combinedQueries != null && + loadingSourcerer != null && + !loadingSourcerer && + !isEmpty(start) && + !isEmpty(end) && + combinedQueries?.filterQuery !== undefined, + [combinedQueries, end, loadingSourcerer, start] + ); + + const timelineQuerySortField = useMemo(() => { + return sort.map(({ columnId, columnType, esTypes, sortDirection }) => ({ + field: columnId, + direction: sortDirection as Direction, + esTypes: esTypes ?? [], + type: columnType, + })); + }, [sort]); + + const { defaultColumns } = useTimelineColumns(columns); + + const [dataLoadingState, { totalCount }] = useTimelineEvents({ + dataViewId, + endDate: end, + fields: emptyFieldsList, + filterQuery: combinedQueries?.filterQuery, + id: timelineId, + indexNames: selectedPatterns, + language: kqlQuery.language, + limit: 0, // We only care about the totalCount here + runtimeMappings: sourcererDataView?.runtimeFieldMap as RunTimeMappings, + skip: !canQueryTimeline, + sort: timelineQuerySortField, + startDate: start, + timerangeKind, + }); + + useEffect(() => { + dispatch( + timelineActions.initializeTimelineSettings({ + id: timelineId, + defaultColumns, + }) + ); + }, [dispatch, timelineId, defaultColumns]); + + // NOTE: The timeline is blank after browser FORWARD navigation (after using back button to navigate to + // the previous page from the timeline), yet we still see total count. This is because the timeline + // is not getting refreshed when using browser navigation. + const showEventsCountBadge = !isBlankTimeline && totalCount >= 0; + + // + // Sync the timerange + const timelineFilters = useTimelineDataFilters(isActiveTimeline(timelineId)); + useEffect(() => { + timelineDataService.query.timefilter.timefilter.setTime({ + from: timelineFilters.from, + to: timelineFilters.to, + }); + }, [timelineDataService.query.timefilter.timefilter, timelineFilters.from, timelineFilters.to]); + + // Sync the base query + useEffect(() => { + timelineDataService.query.queryString.setQuery( + // We're using the base query of all combined queries here, to account for all + // of timeline's query dependencies (data providers, query etc.) + combinedQueries?.baseKqlQuery || { language: kqlQueryLanguage, query: '' } + ); + }, [timelineDataService, combinedQueries, kqlQueryLanguage]); + // + + if (!showEventsCountBadge) return null; + + return dataLoadingState === DataLoadingState.loading || + dataLoadingState === DataLoadingState.loadingMore ? ( + + ) : ( + {totalCount} + ); +}; + +const TimelineQueryTabEventsCount = React.memo(TimelineQueryTabEventsCountComponent); + +// eslint-disable-next-line import/no-default-export +export { TimelineQueryTabEventsCount as default }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx index 99d65ef5101aa..8036806bdf756 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx @@ -10,14 +10,16 @@ import React from 'react'; import { TimelineDataTable } from '.'; import { TimelineId, TimelineTabs } from '../../../../../../common/types'; import { DataLoadingState } from '@kbn/unified-data-table'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { DataView } from '@kbn/data-views-plugin/common'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { useSourcererDataView } from '../../../../../sourcerer/containers'; import type { ComponentProps } from 'react'; import { getColumnHeaders } from '../../body/column_headers/helpers'; import { mockSourcererScope } from '../../../../../sourcerer/containers/mocks'; import * as timelineActions from '../../../../store/actions'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { defaultUdtHeaders } from '../../body/column_headers/default_headers'; +import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks'; jest.mock('../../../../../sourcerer/containers'); @@ -54,6 +56,11 @@ type TestComponentProps = Partial> & { // that is why we are setting it to 10s const SPECIAL_TEST_TIMEOUT = 50000; +const mockDataView = new DataView({ + spec: mockSourcererScope.sourcererDataView, + fieldFormats: fieldFormatsMock, +}); + const TestComponent = (props: TestComponentProps) => { const { store = createMockStore(), ...restProps } = props; useSourcererDataView(); @@ -62,6 +69,7 @@ const TestComponent = (props: TestComponentProps) => { = memo( } ); -export const TimelineDataTable = withDataView(TimelineDataTableComponent); +export const TimelineDataTable = React.memo(TimelineDataTableComponent); // eslint-disable-next-line import/no-default-export export { TimelineDataTable as default }; 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 7d9bde02259a4..02eb0707b0366 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 @@ -34,6 +34,8 @@ import { DataLoadingState } from '@kbn/unified-data-table'; import { getColumnHeaders } from '../body/column_headers/helpers'; import { defaultUdtHeaders } from '../body/column_headers/default_headers'; import type { ColumnHeaderType } from '../../../../../common/types'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks'; jest.mock('../../../containers', () => ({ useTimelineEvents: jest.fn(), @@ -85,6 +87,11 @@ const SPECIAL_TEST_TIMEOUT = 50000; const localMockedTimelineData = structuredClone(mockTimelineData); +const mockDataView = new DataView({ + spec: mockSourcererScope.sourcererDataView, + fieldFormats: fieldFormatsMock, +}); + const TestComponent = ( props: Partial> & { show?: boolean } ) => { @@ -92,6 +99,7 @@ const TestComponent = ( const testComponentDefaultProps: ComponentProps = { columns: getColumnHeaders(columnsToDisplay, mockSourcererScope.browserFields), activeTab: TimelineTabs.query, + dataView: mockDataView, rowRenderers: [], timelineId: TimelineId.test, itemsPerPage: 10, @@ -513,50 +521,14 @@ 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 render', + async () => { + renderTestComponents(); + expect(await screen.queryByTestId('timeline-sidebar')).toBeInTheDocument(); + }, + SPECIAL_TEST_TIMEOUT + ); it( 'should be able to add filters', 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 d350b4b530808..6d1a3f30b8582 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 @@ -5,8 +5,8 @@ * 2.0. */ import type { EuiDataGridProps } from '@elastic/eui'; +import React, { useMemo, useCallback, useState, useRef } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHideFor } from '@elastic/eui'; -import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { generateFilters } from '@kbn/data-plugin/public'; @@ -26,8 +26,6 @@ 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'; import { useKibana } from '../../../../common/lib/kibana'; @@ -47,7 +45,6 @@ import TimelineDataTable from './data_table'; import { timelineActions } from '../../../store'; import { getFieldsListCreationOptions } from './get_fields_list_creation_options'; import { defaultUdtHeaders } from '../body/column_headers/default_headers'; -import { getTimelineShowStatusByIdSelector } from '../../../store/selectors'; const TimelineBodyContainer = styled.div.attrs(({ className = '' }) => ({ className: `${className}`, @@ -346,31 +343,6 @@ 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 && hasTimelineBeenOpenedOnce ? ( + {dataView && ( = ({ onAddFilter={onAddFilter} onFieldEdited={wrappedOnFieldEdited} /> - ) : null} + )} @@ -424,6 +396,7 @@ const UnifiedTimelineComponent: React.FC = ({ = ({ ); }; -export const UnifiedTimeline = React.memo(withDataView(UnifiedTimelineComponent)); +export const UnifiedTimeline = React.memo(UnifiedTimelineComponent); // eslint-disable-next-line import/no-default-export export { UnifiedTimeline as default }; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index d7941eab9fd68..6d3acc55b4a64 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -219,22 +219,27 @@ export const useTimelineEventsHandler = ({ setActiveBatch(0); }, [limit]); - const [timelineResponse, setTimelineResponse] = useState({ - id, - inspect: { - dsl: [], - response: [], - }, - refetch: () => {}, - totalCount: -1, - pageInfo: { - activePage: 0, - querySize: 0, - }, - events: [], - loadNextBatch, - refreshedAt: 0, - }); + const defaultTimelineResponse = useMemo( + () => ({ + id, + inspect: { + dsl: [], + response: [], + }, + refetch: () => {}, + totalCount: -1, + pageInfo: { + activePage: 0, + querySize: 0, + }, + events: [], + loadNextBatch, + refreshedAt: 0, + }), + [id, loadNextBatch] + ); + + const [timelineResponse, setTimelineResponse] = useState(defaultTimelineResponse); const timelineSearch = useCallback( async ( @@ -375,95 +380,98 @@ export const useTimelineEventsHandler = ({ return; } - setTimelineRequest((prevRequest) => { - const prevEqlRequest = prevRequest as TimelineEqlRequestOptionsInput; - const prevSearchParameters = { - defaultIndex: prevRequest?.defaultIndex ?? [], - filterQuery: prevRequest?.filterQuery ?? '', - sort: prevRequest?.sort ?? initSortDefault, - timerange: prevRequest?.timerange ?? {}, - runtimeMappings: (prevRequest?.runtimeMappings ?? {}) as unknown as RunTimeMappings, - ...deStructureEqlOptions(prevEqlRequest), - }; - - const timerange = - startDate && endDate - ? { timerange: { interval: '12h', from: startDate, to: endDate } } - : {}; - const currentSearchParameters = { - defaultIndex: indexNames, - filterQuery: createFilter(filterQuery), - sort, - runtimeMappings: runtimeMappings ?? {}, - ...timerange, - ...deStructureEqlOptions(eqlOptions), - }; - - const areSearchParamsSame = deepEqual(prevSearchParameters, currentSearchParameters); - - const newActiveBatch = !areSearchParamsSame ? 0 : activeBatch; + // Only set timeline request when an actual query exists + if (filterQuery || eqlOptions?.query) { + setTimelineRequest((prevRequest) => { + const prevEqlRequest = prevRequest as TimelineEqlRequestOptionsInput; + const prevSearchParameters = { + defaultIndex: prevRequest?.defaultIndex ?? [], + filterQuery: prevRequest?.filterQuery ?? '', + sort: prevRequest?.sort ?? initSortDefault, + timerange: prevRequest?.timerange ?? {}, + runtimeMappings: (prevRequest?.runtimeMappings ?? {}) as unknown as RunTimeMappings, + ...deStructureEqlOptions(prevEqlRequest), + }; - /* - * optimization to avoid unnecessary network request when a field - * has already been fetched - * - */ + const timerange = + startDate && endDate + ? { timerange: { interval: '12h', from: startDate, to: endDate } } + : {}; + const currentSearchParameters = { + defaultIndex: indexNames, + filterQuery: createFilter(filterQuery), + sort, + runtimeMappings: runtimeMappings ?? {}, + ...timerange, + ...deStructureEqlOptions(eqlOptions), + }; - let finalFieldRequest = fields; + const areSearchParamsSame = deepEqual(prevSearchParameters, currentSearchParameters); - const newFieldsRequested = fields.filter( - (field) => !prevRequest?.fieldRequested?.includes(field) - ); - if (newFieldsRequested.length > 0) { - finalFieldRequest = [...(prevRequest?.fieldRequested ?? []), ...newFieldsRequested]; - } else { - finalFieldRequest = prevRequest?.fieldRequested ?? []; - } + const newActiveBatch = !areSearchParamsSame ? 0 : activeBatch; - let newPagination = { /* + * optimization to avoid unnecessary network request when a field + * has already been fetched * - * fetches data cumulatively for the batches upto the activeBatch - * This is needed because, we want to get incremental data as well for the old batches - * For example, newly requested fields - * - * */ - activePage: newActiveBatch, - querySize: limit, - }; + */ + + let finalFieldRequest = fields; + + const newFieldsRequested = fields.filter( + (field) => !prevRequest?.fieldRequested?.includes(field) + ); + if (newFieldsRequested.length > 0) { + finalFieldRequest = [...(prevRequest?.fieldRequested ?? []), ...newFieldsRequested]; + } else { + finalFieldRequest = prevRequest?.fieldRequested ?? []; + } - if (newFieldsRequested.length > 0) { - newPagination = { - activePage: 0, - querySize: (newActiveBatch + 1) * limit, + let newPagination = { + /* + * + * fetches data cumulatively for the batches upto the activeBatch + * This is needed because, we want to get incremental data as well for the old batches + * For example, newly requested fields + * + * */ + activePage: newActiveBatch, + querySize: limit, }; - } - const currentRequest = { - defaultIndex: indexNames, - factoryQueryType: TimelineEventsQueries.all, - fieldRequested: finalFieldRequest, - fields: finalFieldRequest, - filterQuery: createFilter(filterQuery), - pagination: newPagination, - language, - runtimeMappings, - sort, - ...timerange, - ...(eqlOptions ? eqlOptions : {}), - } as const; - - if (activeBatch !== newActiveBatch) { - setActiveBatch(newActiveBatch); - if (id === TimelineId.active) { - activeTimeline.setActivePage(newActiveBatch); + if (newFieldsRequested.length > 0) { + newPagination = { + activePage: 0, + querySize: (newActiveBatch + 1) * limit, + }; } - } - if (!deepEqual(prevRequest, currentRequest)) { - return currentRequest; - } - return prevRequest; - }); + + const currentRequest = { + defaultIndex: indexNames, + factoryQueryType: TimelineEventsQueries.all, + fieldRequested: finalFieldRequest, + fields: finalFieldRequest, + filterQuery: createFilter(filterQuery), + pagination: newPagination, + language, + runtimeMappings, + sort, + ...timerange, + ...(eqlOptions ? eqlOptions : {}), + } as const; + + if (activeBatch !== newActiveBatch) { + setActiveBatch(newActiveBatch); + if (id === TimelineId.active) { + activeTimeline.setActivePage(newActiveBatch); + } + } + if (!deepEqual(prevRequest, currentRequest)) { + return currentRequest; + } + return prevRequest; + }); + } }, [ dispatch, indexNames, @@ -486,24 +494,9 @@ export const useTimelineEventsHandler = ({ */ useEffect(() => { if (isEmpty(filterQuery)) { - setTimelineResponse({ - id, - inspect: { - dsl: [], - response: [], - }, - refetch: () => {}, - totalCount: -1, - pageInfo: { - activePage: 0, - querySize: 0, - }, - events: [], - loadNextBatch, - refreshedAt: 0, - }); + setTimelineResponse(defaultTimelineResponse); } - }, [filterQuery, id, loadNextBatch]); + }, [defaultTimelineResponse, filterQuery]); const timelineSearchHandler = useCallback( async (onNextHandler?: OnNextResponseHandler) => { @@ -529,6 +522,8 @@ export const useTimelineEventsHandler = ({ return [loading, finalTimelineLineResponse, timelineSearchHandler]; }; +const defaultEvents: TimelineItem[][] = []; + export const useTimelineEvents = ({ dataViewId, endDate, @@ -545,7 +540,7 @@ export const useTimelineEvents = ({ skip = false, timerangeKind, }: UseTimelineEventsProps): [DataLoadingState, TimelineArgs] => { - const [eventsPerPage, setEventsPerPage] = useState([[]]); + const [eventsPerPage, setEventsPerPage] = useState(defaultEvents); const [dataLoadingState, timelineResponse, timelineSearchHandler] = useTimelineEventsHandler({ dataViewId, endDate, @@ -576,9 +571,15 @@ export const useTimelineEvents = ({ const { activePage, querySize } = timelineResponse.pageInfo; setEventsPerPage((prev) => { - let result = [...prev]; + let result = structuredClone(prev); + const newEventsLength = timelineResponse.events.length; + const oldEventsLength = result.length; + if (querySize === limit && activePage > 0) { result[activePage] = timelineResponse.events; + } else if (oldEventsLength === 0 && newEventsLength === 0) { + // don't change array reference if no actual changes take place + result = prev; } else { result = [timelineResponse.events]; } diff --git a/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx index 156c1c06a0461..2cce03b3ab70f 100644 --- a/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx @@ -28,6 +28,11 @@ jest.mock('../components/timeline', () => ({ })); jest.mock('../../common/hooks/timeline/use_timeline_save_prompt'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => jest.fn(), +})); + describe('TimelineWrapper', () => { const props = { onAppLeave: jest.fn(),