mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Performance][Security Solution][2/4] - Timeline Performance (#212478)
## Summary Part 2 of https://github.com/elastic/kibana/pull/212173 ### Testing For setup see testing section here: https://github.com/elastic/kibana/pull/212173#issue-2870522020 **Areas/How to test:** - For the following pages, test there are no `fields` api requests in the inspector network tab when visiting from another page. IF YOU REFRESH on any of these pages, you will see these requests as they are called by the Query Search Bar and the `useInitSourcerer` call - Cases Page - Dashboard Page - Timelines Page - Timeline - All Tabs - Does it show the loading screen on first interaction? - Does the `fields` api fire on first interaction with the tab - When you navigate back to those tabs, do they not re-render? - All other pages hosting timeline - Do you feel like the performance is generally better? ### Background When investigating the performance of the security solution application, one of the issues that was observed was queries to the `fields` api on pages that had no reason making that request (such as Cases, or the Dashboards list view). This was due to the background background loaded tabs of timeline loading the relevant `dataView` necessary for their search functionality. When the fields request is significantly large this can have a massive impact on the experience of users on pages that should be relatively responsive. To fix this a few changes were made. 1. First the `withDataView` HOC was removed as it was only used in 2 components that shared a parent - child relationship, and the child `UnifiedTimeline` was only used in the parent. The hook that HOC calls was not caching the dataView being created, so `dataView.create` was being called up to 6 times unnecessarily. Now it is only called once in each tab. 2. A new wrapper `OnDemandRenderer` (open to different naming 😅) was created that will not render any of the nested tabs until they are opened. Once they are opened, they stay in memory, to avoid re-calling expensive api's every time a user switches tabs. _Note_: There is currently a known issue where navigating between various routes in security solution causes the whole application to unmount and re-mount. Which means every page change will lead to timeline needing to be re-loaded when the tab is opened. This is being resolved in a separate effort. 3. Additional checks were added to the `useTimelineEvents` hook to limit additional re-renders caused by unnecessary reference changes when the underlying values never actually change ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### Identify risks
This commit is contained in:
parent
93adbd8c0e
commit
2d8f3c1544
16 changed files with 706 additions and 272 deletions
|
@ -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';
|
||||
|
||||
|
|
|
@ -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<TimelineBottomBarProps>(
|
|||
{title}
|
||||
</EuiLink>
|
||||
</EuiFlexItem>
|
||||
{!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
|
||||
<EuiFlexItem grow={false} data-test-subj="timeline-event-count-badge">
|
||||
<TimelineEventsCountBadge />
|
||||
<TimelineQueryTabEventsCount timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<typeof UnifiedTimeline> {
|
||||
export interface UnifiedTimelineBodyProps
|
||||
extends Omit<ComponentProps<typeof UnifiedTimeline>, '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"
|
||||
>
|
||||
<RootDragDropProvider>
|
||||
<UnifiedTimeline
|
||||
columns={columnsHeader}
|
||||
rowRenderers={rowRenderers}
|
||||
isSortEnabled={isSortEnabled}
|
||||
timelineId={timelineId}
|
||||
itemsPerPage={itemsPerPage}
|
||||
itemsPerPageOptions={itemsPerPageOptions}
|
||||
sort={sort}
|
||||
events={events}
|
||||
refetch={refetch}
|
||||
dataLoadingState={dataLoadingState}
|
||||
totalCount={totalCount}
|
||||
onFetchMoreRecords={onFetchMoreRecords}
|
||||
activeTab={activeTab}
|
||||
updatedAt={updatedAt}
|
||||
isTextBasedQuery={false}
|
||||
trailingControlColumns={trailingControlColumns}
|
||||
leadingControlColumns={leadingControlColumns}
|
||||
onUpdatePageIndex={onUpdatePageIndex}
|
||||
/>
|
||||
{dataView ? (
|
||||
<UnifiedTimeline
|
||||
columns={columnsHeader}
|
||||
dataView={dataView}
|
||||
rowRenderers={rowRenderers}
|
||||
isSortEnabled={isSortEnabled}
|
||||
timelineId={timelineId}
|
||||
itemsPerPage={itemsPerPage}
|
||||
itemsPerPageOptions={itemsPerPageOptions}
|
||||
sort={sort}
|
||||
events={events}
|
||||
refetch={refetch}
|
||||
dataLoadingState={dataLoadingState}
|
||||
totalCount={totalCount}
|
||||
onFetchMoreRecords={onFetchMoreRecords}
|
||||
activeTab={activeTab}
|
||||
updatedAt={updatedAt}
|
||||
isTextBasedQuery={false}
|
||||
trailingControlColumns={trailingControlColumns}
|
||||
leadingControlColumns={leadingControlColumns}
|
||||
onUpdatePageIndex={onUpdatePageIndex}
|
||||
/>
|
||||
) : (
|
||||
<DataViewErrorComponent />
|
||||
)}
|
||||
</RootDragDropProvider>
|
||||
</StyledUnifiedTableFlexItem>
|
||||
</StyledTableFlexGroup>
|
||||
|
|
|
@ -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 });
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { pick } from 'lodash/fp';
|
||||
import { EuiPanel, EuiProgress, EuiText } 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 TimelineBody = styled.div`
|
||||
height: 100%;
|
||||
|
@ -37,7 +38,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[];
|
||||
|
|
|
@ -45,17 +45,7 @@ import { selectTimelineById, selectTimelineESQLSavedSearchId } from '../../../st
|
|||
import { fetchNotesBySavedObjectIds, makeSelectNotesBySavedObjectId } 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 = <P extends {}, R = {}>(
|
|||
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')),
|
||||
<TimelineTabFallback />
|
||||
);
|
||||
const EqlTab = tabWithSuspense(
|
||||
lazy(() => import('./eql')),
|
||||
<TimelineTabFallback />
|
||||
);
|
||||
const GraphTab = tabWithSuspense(
|
||||
lazy(() => import('./graph')),
|
||||
<TimelineTabFallback />
|
||||
);
|
||||
const NotesTab = tabWithSuspense(
|
||||
lazy(() => import('./notes')),
|
||||
<TimelineTabFallback />
|
||||
);
|
||||
const PinnedTab = tabWithSuspense(
|
||||
lazy(() => import('./pinned')),
|
||||
<TimelineTabFallback />
|
||||
);
|
||||
const SessionTab = tabWithSuspense(
|
||||
lazy(() => import('./session')),
|
||||
<TimelineTabFallback />
|
||||
);
|
||||
const EsqlTab = tabWithSuspense(
|
||||
lazy(() => import('./esql')),
|
||||
<TimelineTabFallback />
|
||||
);
|
||||
|
||||
interface BasicTimelineTab {
|
||||
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
|
||||
rowRenderers: RowRenderer[];
|
||||
|
@ -138,60 +150,60 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
|
|||
[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 (
|
||||
<>
|
||||
<HideShowContainer
|
||||
$isVisible={TimelineTabs.query === activeTimelineTab}
|
||||
data-test-subj={`timeline-tab-content-${TimelineTabs.query}`}
|
||||
<LazyTimelineTabRenderer
|
||||
timelineId={timelineId}
|
||||
shouldShowTab={TimelineTabs.query === activeTimelineTab}
|
||||
dataTestSubj={`timeline-tab-content-${TimelineTabs.query}`}
|
||||
>
|
||||
<QueryTab
|
||||
renderCellValue={renderCellValue}
|
||||
rowRenderers={rowRenderers}
|
||||
timelineId={timelineId}
|
||||
/>
|
||||
</HideShowContainer>
|
||||
</LazyTimelineTabRenderer>
|
||||
{showTimeline && shouldShowESQLTab && activeTimelineTab === TimelineTabs.esql && (
|
||||
<HideShowContainer
|
||||
$isVisible={true}
|
||||
data-test-subj={`timeline-tab-content-${TimelineTabs.esql}`}
|
||||
<LazyTimelineTabRenderer
|
||||
timelineId={timelineId}
|
||||
shouldShowTab={true}
|
||||
dataTestSubj={`timeline-tab-content-${TimelineTabs.esql}`}
|
||||
>
|
||||
<EsqlTab timelineId={timelineId} />
|
||||
</HideShowContainer>
|
||||
</LazyTimelineTabRenderer>
|
||||
)}
|
||||
<HideShowContainer
|
||||
$isVisible={TimelineTabs.pinned === activeTimelineTab}
|
||||
data-test-subj={`timeline-tab-content-${TimelineTabs.pinned}`}
|
||||
<LazyTimelineTabRenderer
|
||||
timelineId={timelineId}
|
||||
shouldShowTab={TimelineTabs.pinned === activeTimelineTab}
|
||||
dataTestSubj={`timeline-tab-content-${TimelineTabs.pinned}`}
|
||||
>
|
||||
<PinnedTab
|
||||
renderCellValue={renderCellValue}
|
||||
rowRenderers={rowRenderers}
|
||||
timelineId={timelineId}
|
||||
/>
|
||||
</HideShowContainer>
|
||||
</LazyTimelineTabRenderer>
|
||||
{timelineType === TimelineTypeEnum.default && (
|
||||
<HideShowContainer
|
||||
$isVisible={TimelineTabs.eql === activeTimelineTab}
|
||||
data-test-subj={`timeline-tab-content-${TimelineTabs.eql}`}
|
||||
<LazyTimelineTabRenderer
|
||||
timelineId={timelineId}
|
||||
shouldShowTab={TimelineTabs.eql === activeTimelineTab}
|
||||
dataTestSubj={`timeline-tab-content-${TimelineTabs.eql}`}
|
||||
>
|
||||
<EqlTab
|
||||
renderCellValue={renderCellValue}
|
||||
rowRenderers={rowRenderers}
|
||||
timelineId={timelineId}
|
||||
/>
|
||||
</HideShowContainer>
|
||||
</LazyTimelineTabRenderer>
|
||||
)}
|
||||
<HideShowContainer
|
||||
$isVisible={isGraphOrNotesTabs}
|
||||
<LazyTimelineTabRenderer
|
||||
timelineId={timelineId}
|
||||
shouldShowTab={isGraphOrNotesTabs}
|
||||
isOverflowYScroll={activeTimelineTab === TimelineTabs.session}
|
||||
data-test-subj={`timeline-tab-content-${TimelineTabs.graph}-${TimelineTabs.notes}`}
|
||||
dataTestSubj={`timeline-tab-content-${TimelineTabs.graph}-${TimelineTabs.notes}`}
|
||||
>
|
||||
{isGraphOrNotesTabs && getTab(activeTimelineTab)}
|
||||
</HideShowContainer>
|
||||
{isGraphOrNotesTabs ? getTab(activeTimelineTab) : null}
|
||||
</LazyTimelineTabRenderer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<LazyTimelineTabRendererProps>) => (
|
||||
<LazyTimelineTabRenderer {...defaultProps} {...restProps}>
|
||||
<div>{children ?? 'test component'}</div>
|
||||
</LazyTimelineTabRenderer>
|
||||
);
|
||||
const renderTestComponents = (props?: Partial<LazyTimelineTabRendererProps>) => {
|
||||
const { children, ...restProps } = props ?? {};
|
||||
return render(<TestComponent {...restProps}>{children}</TestComponent>);
|
||||
};
|
||||
|
||||
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 <div>{testChildString}</div>;
|
||||
};
|
||||
|
||||
const RerenderTestComponent = (props?: Partial<LazyTimelineTabRendererProps>) => (
|
||||
<TestComponent {...props}>
|
||||
<TestChild />
|
||||
</TestComponent>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockUseDeepEqualSelector.mockReturnValue({ show: true });
|
||||
});
|
||||
|
||||
it('should NOT re-render children after the first render', () => {
|
||||
const { queryByText } = render(<RerenderTestComponent />);
|
||||
expect(queryByText(testChildString)).toBeInTheDocument();
|
||||
expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT re-render children even if timeline show status changes', () => {
|
||||
const { rerender, queryByText } = render(<RerenderTestComponent />);
|
||||
mockUseDeepEqualSelector.mockReturnValue({ show: false });
|
||||
rerender(<RerenderTestComponent />);
|
||||
expect(queryByText(testChildString)).toBeInTheDocument();
|
||||
expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT re-render children even if tab visibility status changes', () => {
|
||||
const { rerender, queryByText } = render(<RerenderTestComponent />);
|
||||
rerender(<RerenderTestComponent shouldShowTab={false} />);
|
||||
rerender(<RerenderTestComponent shouldShowTab={true} />);
|
||||
expect(queryByText(testChildString)).toBeInTheDocument();
|
||||
expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should re-render if the component is unmounted and remounted', () => {
|
||||
const { rerender, queryByText, unmount } = render(<RerenderTestComponent />);
|
||||
unmount();
|
||||
rerender(<RerenderTestComponent shouldShowTab={true} />);
|
||||
expect(queryByText(testChildString)).toBeInTheDocument();
|
||||
expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
<div
|
||||
// The shouldShowTab check here is necessary for the flex container to accurately size to the modal window when it's opened
|
||||
css={css`
|
||||
display: ${shouldShowTab ? 'flex' : 'none'};
|
||||
overflow: ${isOverflowYScroll ? 'hidden scroll' : 'hidden'};
|
||||
flex: 1;
|
||||
`}
|
||||
data-test-subj={dataTestSubj}
|
||||
>
|
||||
{hasTimelineTabBeenOpenedOnce ? children : <TimelineTabFallback />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
LazyTimelineTabRenderer.displayName = 'LazyTimelineTabRenderer';
|
||||
|
||||
export const TimelineTabFallback = () => (
|
||||
<EuiFlexGroup direction="row" justifyContent="spaceAround">
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
css={css`
|
||||
justify-content: center;
|
||||
`}
|
||||
>
|
||||
<EuiLoadingElastic size="xxl" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
|
@ -0,0 +1,227 @@
|
|||
/*
|
||||
* 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,
|
||||
// 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: sourcererDataView,
|
||||
browserFields,
|
||||
filters,
|
||||
kqlQuery,
|
||||
kqlMode,
|
||||
});
|
||||
}, [esQueryConfig, dataProviders, sourcererDataView, 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;
|
||||
|
||||
// <Synchronisation of the timeline data service>
|
||||
// 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]);
|
||||
// </Synchronisation of the timeline data service>
|
||||
|
||||
if (!showEventsCountBadge) return null;
|
||||
|
||||
return dataLoadingState === DataLoadingState.loading ||
|
||||
dataLoadingState === DataLoadingState.loadingMore ? (
|
||||
<EuiLoadingSpinner size="s" />
|
||||
) : (
|
||||
<EventsCountBadge data-test-subj="query-events-count">{totalCount}</EventsCountBadge>
|
||||
);
|
||||
};
|
||||
|
||||
const TimelineQueryTabEventsCount = React.memo(TimelineQueryTabEventsCountComponent);
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { TimelineQueryTabEventsCount as default };
|
|
@ -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<ComponentProps<typeof TimelineDataTable>> & {
|
|||
// 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) => {
|
|||
<TimelineDataTable
|
||||
columns={initialEnrichedColumns}
|
||||
columnIds={initialEnrichedColumnsIds}
|
||||
dataView={mockDataView}
|
||||
activeTab={TimelineTabs.query}
|
||||
timelineId={TimelineId.test}
|
||||
itemsPerPage={50}
|
||||
|
|
|
@ -24,7 +24,6 @@ import { DocumentDetailsRightPanelKey } from '../../../../../flyout/document_det
|
|||
import { selectTimelineById } from '../../../../store/selectors';
|
||||
import { RowRendererCount } from '../../../../../../common/api/timeline';
|
||||
import { EmptyComponent } from '../../../../../common/lib/cell_actions/helpers';
|
||||
import { withDataView } from '../../../../../common/components/with_data_view';
|
||||
import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
|
||||
import type { TimelineItem } from '../../../../../../common/search_strategy';
|
||||
import { useKibana } from '../../../../../common/lib/kibana';
|
||||
|
@ -432,7 +431,7 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
|
|||
}
|
||||
);
|
||||
|
||||
export const TimelineDataTable = withDataView<DataTableProps>(TimelineDataTableComponent);
|
||||
export const TimelineDataTable = React.memo(TimelineDataTableComponent);
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { TimelineDataTable as default };
|
||||
|
|
|
@ -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<ComponentProps<typeof UnifiedTimeline>> & { show?: boolean }
|
||||
) => {
|
||||
|
@ -92,6 +99,7 @@ const TestComponent = (
|
|||
const testComponentDefaultProps: ComponentProps<typeof QueryTabContent> = {
|
||||
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<PropsWithChildren<unknown>>;
|
||||
beforeEach(() => {
|
||||
const freshStore = createMockStore();
|
||||
// eslint-disable-next-line react/display-name
|
||||
TestProviderWithNewStore = ({ children }) => {
|
||||
return <TestProviders store={freshStore}>{children}</TestProviders>;
|
||||
};
|
||||
});
|
||||
it(
|
||||
'should not render when timeline has never been opened',
|
||||
async () => {
|
||||
render(<TestComponent show={false} />, {
|
||||
wrapper: TestProviderWithNewStore,
|
||||
});
|
||||
expect(await screen.queryByTestId('timeline-sidebar')).not.toBeInTheDocument();
|
||||
},
|
||||
SPECIAL_TEST_TIMEOUT
|
||||
);
|
||||
|
||||
it(
|
||||
'should render when timeline has been opened',
|
||||
async () => {
|
||||
render(<TestComponent />, {
|
||||
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(<TestComponent />, {
|
||||
wrapper: TestProviderWithNewStore,
|
||||
});
|
||||
rerender(<TestComponent show={false} />);
|
||||
// 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',
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
import type { EuiDataGridProps } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiHideFor, useEuiTheme } from '@elastic/eui';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { generateFilters } from '@kbn/data-plugin/public';
|
||||
import type { DataView, DataViewField } from '@kbn/data-plugin/common';
|
||||
|
@ -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}`,
|
||||
|
@ -347,31 +344,6 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
|
|||
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 (
|
||||
<TimelineBodyContainer className="timelineBodyContainer" ref={setSidebarContainer}>
|
||||
<TimelineResizableLayout
|
||||
|
@ -380,7 +352,7 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
|
|||
sidebarPanel={
|
||||
<SidebarPanelFlexGroup gutterSize="none">
|
||||
<EuiFlexItem className="sidebarContainer">
|
||||
{dataView && hasTimelineBeenOpenedOnce ? (
|
||||
{dataView && (
|
||||
<UnifiedFieldListSidebarContainer
|
||||
ref={unifiedFieldListContainerRef}
|
||||
showFieldList
|
||||
|
@ -396,7 +368,7 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
|
|||
onAddFilter={onAddFilter}
|
||||
onFieldEdited={wrappedOnFieldEdited}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiHideFor sizes={HIDE_FOR_SIZES}>
|
||||
<EuiFlexItem
|
||||
|
@ -430,6 +402,7 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
|
|||
<DataGridMemoized
|
||||
columns={columns}
|
||||
columnIds={currentColumnIds}
|
||||
dataView={dataView}
|
||||
rowRenderers={rowRenderers}
|
||||
timelineId={timelineId}
|
||||
isSortEnabled={isSortEnabled}
|
||||
|
@ -463,6 +436,6 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const UnifiedTimeline = React.memo(withDataView<Props>(UnifiedTimelineComponent));
|
||||
export const UnifiedTimeline = React.memo(UnifiedTimelineComponent);
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { UnifiedTimeline as default };
|
||||
|
|
|
@ -219,22 +219,27 @@ export const useTimelineEventsHandler = ({
|
|||
setActiveBatch(0);
|
||||
}, [limit]);
|
||||
|
||||
const [timelineResponse, setTimelineResponse] = useState<TimelineArgs>({
|
||||
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<TimelineArgs>(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;
|
||||
|
||||
/*
|
||||
* optimization to avoid unnecessary network request when a field
|
||||
* has already been fetched
|
||||
*
|
||||
*/
|
||||
|
||||
let finalFieldRequest = fields;
|
||||
|
||||
const newFieldsRequested = fields.filter(
|
||||
(field) => !prevRequest?.fieldRequested?.includes(field)
|
||||
);
|
||||
if (newFieldsRequested.length > 0) {
|
||||
finalFieldRequest = [...(prevRequest?.fieldRequested ?? []), ...newFieldsRequested];
|
||||
} else {
|
||||
finalFieldRequest = prevRequest?.fieldRequested ?? [];
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
if (newFieldsRequested.length > 0) {
|
||||
newPagination = {
|
||||
activePage: 0,
|
||||
querySize: (newActiveBatch + 1) * limit,
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
const currentRequest = {
|
||||
defaultIndex: indexNames,
|
||||
factoryQueryType: TimelineEventsQueries.all,
|
||||
fieldRequested: finalFieldRequest,
|
||||
fields: finalFieldRequest,
|
||||
filterQuery: createFilter(filterQuery),
|
||||
pagination: newPagination,
|
||||
language,
|
||||
runtimeMappings,
|
||||
sort,
|
||||
...timerange,
|
||||
...(eqlOptions ? eqlOptions : {}),
|
||||
} as const;
|
||||
const timerange =
|
||||
startDate && endDate
|
||||
? { timerange: { interval: '12h', from: startDate, to: endDate } }
|
||||
: {};
|
||||
const currentSearchParameters = {
|
||||
defaultIndex: indexNames,
|
||||
filterQuery: createFilter(filterQuery),
|
||||
sort,
|
||||
runtimeMappings: runtimeMappings ?? {},
|
||||
...timerange,
|
||||
...deStructureEqlOptions(eqlOptions),
|
||||
};
|
||||
|
||||
if (activeBatch !== newActiveBatch) {
|
||||
setActiveBatch(newActiveBatch);
|
||||
if (id === TimelineId.active) {
|
||||
activeTimeline.setActivePage(newActiveBatch);
|
||||
const areSearchParamsSame = deepEqual(prevSearchParameters, currentSearchParameters);
|
||||
|
||||
const newActiveBatch = !areSearchParamsSame ? 0 : activeBatch;
|
||||
|
||||
/*
|
||||
* optimization to avoid unnecessary network request when a field
|
||||
* has already been fetched
|
||||
*
|
||||
*/
|
||||
|
||||
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 (!deepEqual(prevRequest, currentRequest)) {
|
||||
return currentRequest;
|
||||
}
|
||||
return prevRequest;
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
if (newFieldsRequested.length > 0) {
|
||||
newPagination = {
|
||||
activePage: 0,
|
||||
querySize: (newActiveBatch + 1) * 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 (!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<TimelineItem[][]>([[]]);
|
||||
const [eventsPerPage, setEventsPerPage] = useState<TimelineItem[][]>(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];
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue