, '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/solutions/security/plugins/security_solution/public/timelines/components/timeline/context.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/context.ts
new file mode 100644
index 0000000000000..eebd271eec3af
--- /dev/null
+++ b/x-pack/solutions/security/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/solutions/security/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/index.tsx
index e54bfc82399f9..0837e05f4f2a9 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/index.tsx
+++ b/x-pack/solutions/security/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/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx
index 88b134cf00e30..5a2ffe6e8c7af 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx
@@ -45,17 +45,7 @@ import { selectTimelineById, selectTimelineESQLSavedSearchId } from '../../../st
import { fetchNotesBySavedObjectIds, selectSortedNotesBySavedObjectId } from '../../../../notes';
import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
-
-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
@@ -76,13 +66,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[];
@@ -138,60 +150,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/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/lazy_timeline_tab_renderer.test.tsx b/x-pack/solutions/security/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/solutions/security/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/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/lazy_timeline_tab_renderer.tsx b/x-pack/solutions/security/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/solutions/security/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/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/query/events_count.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/query/events_count.tsx
new file mode 100644
index 0000000000000..89d886108d777
--- /dev/null
+++ b/x-pack/solutions/security/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/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx
index 76a88afdb8058..277010473e6bd 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx
+++ b/x-pack/solutions/security/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/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx
index 872f3c760f38b..79acb78023fc8 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx
+++ b/x-pack/solutions/security/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/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx
index d350b4b530808..acd125bf05c33 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx
+++ b/x-pack/solutions/security/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, useEffect } from 'react';
+import React, { useCallback, useMemo, useRef, useState } 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/solutions/security/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/containers/index.tsx
index 7b261981c4062..5ede370e9aabb 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/timelines/containers/index.tsx
+++ b/x-pack/solutions/security/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/test/security_solution_cypress/cypress/tasks/inspect.ts b/x-pack/test/security_solution_cypress/cypress/tasks/inspect.ts
index 11cbd9b74434c..4586d153ee4d9 100644
--- a/x-pack/test/security_solution_cypress/cypress/tasks/inspect.ts
+++ b/x-pack/test/security_solution_cypress/cypress/tasks/inspect.ts
@@ -28,7 +28,7 @@ export const openTableInspectModal = (table: InspectTableMetadata) => {
// wait for table to load
cy.get(table.id).then(($table) => {
if ($table.find(TABLE_LOADER).length > 0) {
- cy.get(TABLE_LOADER).should('not.exist');
+ cy.get(TABLE_LOADER).should('not.be.visible');
}
});
@@ -44,11 +44,12 @@ export const openLensVisualizationsInspectModal = (
.each(($el) => {
// wait for visualization to load
if ($el.find(LOADER_ARIA).length > 0) {
- cy.get(LOADER_ARIA).should('not.exist');
+ cy.get(LOADER_ARIA).should('not.be.visible');
}
cy.wrap($el).realHover();
- cy.wrap($el).find(EMBEDDABLE_PANEL_TOGGLE_ICON).click();
+ // eslint-disable-next-line cypress/no-force
+ cy.wrap($el).find(EMBEDDABLE_PANEL_TOGGLE_ICON).click({ force: true });
cy.get(EMBEDDABLE_PANEL_INSPECT).click();
onOpen();