[Security Solution] Event Renderer Virtualization (#193316)

## Summary

This PR implements virtualization when Event Renderers are enabled.
Ideally from UX pespective nothing should change but from performance
perspective, the event renderers should be scalable.

### Testing checklist

1. UX is working same as before when Event Renderers are enabled.
2. Operations such as increasing page size from `10` to `100` are not
taking as much time as before. Below operations can be used to test.
   a. Closing / Opening Timeline
   b. Changes `Rows per page`
   c. Changes tabs from query to any other and back.

### Before
In below video, you will notice how long it took to change `pageSize` to
100 and all 100 rows are rendered at once.


https://github.com/user-attachments/assets/106669c9-bda8-4b7d-af3f-b64824bde397


### After


https://github.com/user-attachments/assets/356d9e1f-caf1-4f88-9223-0e563939bf6b



> [!Note]
> 1. Also test in small screen. The table should be scrollable but
nothing out of ordinary.
> 2. Additionally, try to load data which has `network_flow` process so
as to create bigger and varied Event Renderers.

---------

Co-authored-by: Cee Chen <constance.chen@elastic.co>
This commit is contained in:
Jatin Kathuria 2024-10-16 19:53:49 +02:00 committed by GitHub
parent 2f76b60b0e
commit fa92a8ede7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 491 additions and 265 deletions

View file

@ -1632,7 +1632,7 @@
"@types/react-router-dom": "^5.3.3",
"@types/react-syntax-highlighter": "^15.4.0",
"@types/react-test-renderer": "^17.0.2",
"@types/react-virtualized": "^9.21.22",
"@types/react-virtualized": "^9.21.30",
"@types/react-window": "^1.8.8",
"@types/react-window-infinite-loader": "^1.0.9",
"@types/redux-actions": "^2.6.1",

View file

@ -513,3 +513,8 @@ export const CASE_ATTACHMENT_ENDPOINT_TYPE_ID = 'endpoint' as const;
*/
export const MAX_MANUAL_RULE_RUN_LOOKBACK_WINDOW_DAYS = 90;
export const MAX_MANUAL_RULE_RUN_BULK_SIZE = 100;
/*
* Whether it is a Jest environment
*/
export const JEST_ENVIRONMENT = typeof jest !== 'undefined';

View file

@ -40,69 +40,67 @@ import { useStatefulRowRenderer } from './use_stateful_row_renderer';
* which focuses the current or next row, respectively.
* - A screen-reader-only message provides additional context and instruction
*/
export const StatefulRowRenderer = ({
ariaRowindex,
containerRef,
event,
lastFocusedAriaColindex,
rowRenderers,
timelineId,
}: {
ariaRowindex: number;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
event: TimelineItem;
lastFocusedAriaColindex: number;
rowRenderers: RowRenderer[];
timelineId: string;
}) => {
const { focusOwnership, onFocus, onKeyDown, onOutsideClick } = useStatefulEventFocus({
export const StatefulRowRenderer = React.memo(
({
ariaRowindex,
colindexAttribute: ARIA_COLINDEX_ATTRIBUTE,
containerRef,
event,
lastFocusedAriaColindex,
onColumnFocused: noop,
rowindexAttribute: ARIA_ROWINDEX_ATTRIBUTE,
});
const { rowRenderer } = useStatefulRowRenderer({
data: event.ecs,
rowRenderers,
});
const content = useMemo(
() =>
rowRenderer && (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<div className={getRowRendererClassName(ariaRowindex)} role="dialog" onFocus={onFocus}>
<EuiOutsideClickDetector onOutsideClick={onOutsideClick}>
<EuiFocusTrap clickOutsideDisables={true} disabled={focusOwnership !== 'owned'}>
<EuiScreenReaderOnly data-test-subj="eventRendererScreenReaderOnly">
<p>{i18n.YOU_ARE_IN_AN_EVENT_RENDERER(ariaRowindex)}</p>
</EuiScreenReaderOnly>
<EuiFlexGroup direction="column" onKeyDown={onKeyDown}>
<EuiFlexItem grow={true}>
{rowRenderer.renderRow({
data: event.ecs,
isDraggable: true,
scopeId: timelineId,
})}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFocusTrap>
</EuiOutsideClickDetector>
</div>
),
[
timelineId,
}: {
ariaRowindex: number;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
event: TimelineItem;
lastFocusedAriaColindex: number;
rowRenderers: RowRenderer[];
timelineId: string;
}) => {
const { focusOwnership, onFocus, onKeyDown, onOutsideClick } = useStatefulEventFocus({
ariaRowindex,
event.ecs,
focusOwnership,
onFocus,
onKeyDown,
onOutsideClick,
rowRenderer,
timelineId,
]
);
colindexAttribute: ARIA_COLINDEX_ATTRIBUTE,
containerRef,
lastFocusedAriaColindex,
onColumnFocused: noop,
rowindexAttribute: ARIA_ROWINDEX_ATTRIBUTE,
});
return content;
};
const { rowRenderer } = useStatefulRowRenderer({
data: event.ecs,
rowRenderers,
});
const row = useMemo(() => {
const result = rowRenderer?.renderRow({
data: event.ecs,
isDraggable: false,
scopeId: timelineId,
});
return result;
}, [rowRenderer, event.ecs, timelineId]);
const content = useMemo(
() =>
rowRenderer && (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<div className={getRowRendererClassName(ariaRowindex)} role="dialog" onFocus={onFocus}>
<EuiOutsideClickDetector onOutsideClick={onOutsideClick}>
<EuiFocusTrap clickOutsideDisables={true} disabled={focusOwnership !== 'owned'}>
<EuiScreenReaderOnly data-test-subj="eventRendererScreenReaderOnly">
<p>{i18n.YOU_ARE_IN_AN_EVENT_RENDERER(ariaRowindex)}</p>
</EuiScreenReaderOnly>
<EuiFlexGroup direction="column" onKeyDown={onKeyDown}>
<EuiFlexItem grow={true}>{row}</EuiFlexItem>
</EuiFlexGroup>
</EuiFocusTrap>
</EuiOutsideClickDetector>
</div>
),
[ariaRowindex, focusOwnership, onFocus, onKeyDown, onOutsideClick, rowRenderer, row]
);
return content;
}
);
StatefulRowRenderer.displayName = 'StatefulRowRenderer';

View file

@ -17,6 +17,7 @@ interface UseStatefulRowRendererArgs {
export function useStatefulRowRenderer(args: UseStatefulRowRendererArgs) {
const { data, rowRenderers } = args;
const rowRenderer = useMemo(() => getRowRenderer({ data, rowRenderers }), [data, rowRenderers]);
const result = useMemo(

View file

@ -5,10 +5,11 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useMemo, useEffect } from 'react';
import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
import type { SortColumnTable } from '@kbn/securitysolution-data-table';
import type { TimelineItem } from '@kbn/timelines-plugin/common';
import { JEST_ENVIRONMENT } from '../../../../../../common/constants';
import { useLicense } from '../../../../../common/hooks/use_license';
import { SourcererScopeName } from '../../../../../sourcerer/store/model';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
@ -21,6 +22,7 @@ import { TimelineControlColumnCellRender } from '../../unified_components/data_t
import type { ColumnHeaderOptions } from '../../../../../../common/types';
import { useTimelineColumns } from './use_timeline_columns';
import type { UnifiedTimelineDataGridCellContext } from '../../types';
import { useTimelineUnifiedDataTableContext } from '../../unified_components/data_table/use_timeline_unified_data_table_context';
interface UseTimelineControlColumnArgs {
columns: ColumnHeaderOptions[];
@ -59,6 +61,58 @@ export const useTimelineControlColumn = ({
const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5;
const { localColumns } = useTimelineColumns(columns);
const RowCellRender = useMemo(
() =>
function TimelineControlColumnCellRenderer(
props: EuiDataGridCellValueElementProps & UnifiedTimelineDataGridCellContext
) {
const ctx = useTimelineUnifiedDataTableContext();
useEffect(() => {
props.setCellProps({
className:
ctx.expanded?.id === events[props.rowIndex]?._id
? 'unifiedDataTable__cell--expanded'
: '',
});
});
/*
* In some cases, when number of events is updated
* but new table is not yet rendered it can result
* in the mismatch between the number of events v/s
* the number of rows in the table currently rendered.
*
* */
if ('rowIndex' in props && props.rowIndex >= events.length) return <></>;
return (
<TimelineControlColumnCellRender
rowIndex={props.rowIndex}
columnId={props.columnId}
timelineId={timelineId}
ariaRowindex={props.rowIndex}
checked={false}
columnValues=""
data={events[props.rowIndex].data}
ecsData={events[props.rowIndex].ecs}
loadingEventIds={EMPTY_STRING_ARRAY}
eventId={events[props.rowIndex]?._id}
index={props.rowIndex}
onEventDetailsPanelOpened={noOp}
onRowSelected={noOp}
refetch={refetch}
showCheckboxes={false}
setEventsLoading={noOp}
setEventsDeleted={noOp}
pinnedEventIds={pinnedEventIds}
eventIdToNoteIds={eventIdToNoteIds}
toggleShowNotes={onToggleShowNotes}
/>
);
},
[events, timelineId, refetch, pinnedEventIds, eventIdToNoteIds, onToggleShowNotes]
);
// We need one less when the unified components are enabled because the document expand is provided by the unified data table
const UNIFIED_COMPONENTS_ACTION_BUTTON_COUNT = ACTION_BUTTON_COUNT - 1;
return useMemo(() => {
@ -84,49 +138,7 @@ export const useTimelineControlColumn = ({
/>
);
},
rowCellRender: (
props: EuiDataGridCellValueElementProps & UnifiedTimelineDataGridCellContext
) => {
/*
* In some cases, when number of events is updated
* but new table is not yet rendered it can result
* in the mismatch between the number of events v/s
* the number of rows in the table currently rendered.
*
* */
if ('rowIndex' in props && props.rowIndex >= events.length) return <></>;
props.setCellProps({
className:
props.expandedEventId === events[props.rowIndex]?._id
? 'unifiedDataTable__cell--expanded'
: '',
});
return (
<TimelineControlColumnCellRender
rowIndex={props.rowIndex}
columnId={props.columnId}
timelineId={timelineId}
ariaRowindex={props.rowIndex}
checked={false}
columnValues=""
data={events[props.rowIndex].data}
ecsData={events[props.rowIndex].ecs}
loadingEventIds={EMPTY_STRING_ARRAY}
eventId={events[props.rowIndex]?._id}
index={props.rowIndex}
onEventDetailsPanelOpened={noOp}
onRowSelected={noOp}
refetch={refetch}
showCheckboxes={false}
setEventsLoading={noOp}
setEventsDeleted={noOp}
pinnedEventIds={pinnedEventIds}
eventIdToNoteIds={eventIdToNoteIds}
toggleShowNotes={onToggleShowNotes}
/>
);
},
rowCellRender: JEST_ENVIRONMENT ? RowCellRender : React.memo(RowCellRender),
}));
} else {
return getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({
@ -142,11 +154,7 @@ export const useTimelineControlColumn = ({
sort,
activeTab,
timelineId,
refetch,
events,
pinnedEventIds,
eventIdToNoteIds,
onToggleShowNotes,
ACTION_BUTTON_COUNT,
RowCellRender,
]);
};

View file

@ -2,32 +2,35 @@
exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = `
.c0 {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
border-bottom: 1px solid 1px solid #343741;
}
.c0 . euiDataGridRowCell--controlColumn {
height: auto;
min-height: 34px;
width: 100%;
height: 100%;
border-bottom: 1px solid #343741;
}
.c0 .udt--customRow {
border-radius: 0;
padding: 6px;
max-width: 1200px;
width: 85vw;
max-width: 1000px;
}
.c0 .euiCommentEvent__body {
background-color: #1d1e24;
.c0 .euiDataGridRowCell--lastColumn.euiDataGridRowCell--controlColumn .euiDataGridRowCell__content {
width: 1000px;
max-width: 1000px;
overflow-x: auto;
-webkit-scrollbar-width: thin;
-moz-scrollbar-width: thin;
-ms-scrollbar-width: thin;
scrollbar-width: thin;
-webkit-scroll-padding: 0 0 0 0,;
-moz-scroll-padding: 0 0 0 0,;
-ms-scroll-padding: 0 0 0 0,;
scroll-padding: 0 0 0 0,;
}
.c0:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--firstColumn,
.c0:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--lastColumn,
.c0:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--controlColumn,
.c0:has(.unifiedDataTable__cell--expanded) .udt--customRow {
.c0 .euiDataGridRow:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--firstColumn,
.c0 .euiDataGridRow:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--lastColumn,
.c0 .euiDataGridRow:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--controlColumn,
.c0 .euiDataGridRow:has(.unifiedDataTable__cell--expanded) .udt--customRow {
background-color: #2e2d25;
}
@ -42,47 +45,76 @@ exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = `
<div>
<div
class="c0 euiDataGridRow "
role="row"
class="c0"
>
<div
class="c1 rawEvent rowCellWrapper rawEvent"
role="row"
data-eui="EuiAutoSizer"
>
<div>
Cell-0-0
<div
class="variable__list"
style="position: relative; height: 600px; width: 1000px; overflow: auto; will-change: transform; direction: ltr; scroll-padding: 0 0 0 0;"
>
<div
class="custom__grid__rows--container"
data-test-subj="customGridRowsContainer"
style="height: 0px; width: 100%; position: relative;"
>
<div
role="row"
style="position: absolute; left: 0px; top: 0px; height: 0px;"
>
<div
class="euiDataGridRow "
role="row"
>
<div
class="c1 rawEvent rowCellWrapper rawEvent"
role="row"
>
<div>
Cell-0-0
</div>
<div>
Cell-0-1
</div>
<div>
Cell-0-2
</div>
</div>
<div>
Cell-0-3
</div>
</div>
</div>
<div
role="row"
style="position: absolute; left: 0px; top: 0px; height: 0px;"
>
<div
class="euiDataGridRow--striped euiDataGridRow euiDataGridRow--striped"
role="row"
>
<div
class="c1 rawEvent rowCellWrapper rawEvent"
role="row"
>
<div>
Cell-1-0
</div>
<div>
Cell-1-1
</div>
<div>
Cell-1-2
</div>
</div>
<div>
Cell-1-3
</div>
</div>
</div>
</div>
</div>
<div>
Cell-0-1
</div>
<div>
Cell-0-2
</div>
</div>
<div>
Cell-0-3
</div>
</div>
<div
class="c0 euiDataGridRow--striped euiDataGridRow euiDataGridRow--striped"
role="row"
>
<div
class="c1 rawEvent rowCellWrapper rawEvent"
role="row"
>
<div>
Cell-1-0
</div>
<div>
Cell-1-1
</div>
<div>
Cell-1-2
</div>
</div>
<div>
Cell-1-3
</div>
</div>
</div>

View file

@ -48,10 +48,10 @@ const defaultProps: CustomTimelineDataGridBodyProps = {
visibleColumns: mockVisibleColumns,
headerRow: <></>,
footerRow: null,
gridWidth: 0,
gridWidth: 1000,
};
const renderTestComponents = (props?: CustomTimelineDataGridBodyProps) => {
const renderTestComponents = (props?: Partial<CustomTimelineDataGridBodyProps>) => {
const finalProps = props ? { ...defaultProps, ...props } : defaultProps;
return render(
@ -88,8 +88,15 @@ describe('CustomTimelineDataGridBody', () => {
(useStatefulRowRenderer as jest.Mock).mockReturnValueOnce({
canShowRowRenderer: true,
});
const { getByText, queryByText } = renderTestComponents();
const { getByTestId, getByText, queryByText } = renderTestComponents();
expect(getByTestId('customGridRowsContainer')).toBeVisible();
expect(queryByText('Cell-0-3')).toBeFalsy();
expect(getByText('Cell-1-3')).toBeInTheDocument();
});
it('should not render grid if gridWidth is 0', () => {
const { queryByTestId } = renderTestComponents({ gridWidth: 0 });
expect(queryByTestId('customGridRowsContainer')).not.toBeInTheDocument();
});
});

View file

@ -5,18 +5,24 @@
* 2.0.
*/
import type { EuiDataGridCustomBodyProps } from '@elastic/eui';
import type { EuiDataGridCustomBodyProps, EuiDataGridRowHeightsOptions } from '@elastic/eui';
import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { EuiTheme } from '@kbn/react-kibana-context-styled';
import { type EuiTheme } from '@kbn/react-kibana-context-styled';
import type { TimelineItem } from '@kbn/timelines-plugin/common';
import type { FC } from 'react';
import React, { memo, useMemo } from 'react';
import type { CSSProperties, FC, PropsWithChildren } from 'react';
import React, { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react';
import styled from 'styled-components';
import { VariableSizeList } from 'react-window';
import { EuiAutoSizer, useEuiTheme } from '@elastic/eui';
import type { RowRenderer } from '../../../../../../common/types';
import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants';
import { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer';
import { getEventTypeRowClassName } from './get_event_type_row_classname';
const defaultAutoHeight: EuiDataGridRowHeightsOptions = {
defaultHeight: 'auto',
};
export type CustomTimelineDataGridBodyProps = EuiDataGridCustomBodyProps & {
rows: Array<DataTableRecord & TimelineItem> | undefined;
enabledRowRenderers: RowRenderer[];
@ -24,9 +30,46 @@ export type CustomTimelineDataGridBodyProps = EuiDataGridCustomBodyProps & {
refetch?: () => void;
};
const VirtualizedCustomDataGridContainer = styled.div<{
$maxWidth?: number;
}>`
width: 100%;
height: 100%;
border-bottom: ${(props) => (props.theme as EuiTheme).eui.euiBorderThin};
.udt--customRow {
border-radius: 0;
padding: ${(props) => (props.theme as EuiTheme).eui.euiDataGridCellPaddingM};
max-width: ${(props) => props.$maxWidth}px;
}
.euiDataGridRowCell--lastColumn.euiDataGridRowCell--controlColumn .euiDataGridRowCell__content {
width: ${(props) => props.$maxWidth}px;
max-width: ${(props) => props.$maxWidth}px;
overflow-x: auto;
scrollbar-width: thin;
scroll-padding: 0 0 0 0,
}
.euiDataGridRow:has(.unifiedDataTable__cell--expanded) {
.euiDataGridRowCell--firstColumn,
.euiDataGridRowCell--lastColumn,
.euiDataGridRowCell--controlColumn,
.udt--customRow {
${({ theme }) => `background-color: ${theme.eui.euiColorHighlight};`}
}
}
}
`;
// THE DataGrid Row default is 34px, but we make ours 40 to account for our row actions
const DEFAULT_UDT_ROW_HEIGHT = 34;
const SCROLLBAR_STYLE: CSSProperties = {
scrollbarWidth: 'thin',
scrollPadding: '0 0 0 0',
overflow: 'auto',
};
/**
*
* In order to render the additional row with every event ( which displays the row-renderer, notes and notes editor)
@ -44,40 +87,170 @@ export const CustomTimelineDataGridBody: FC<CustomTimelineDataGridBodyProps> = m
function CustomTimelineDataGridBody(props) {
const {
Cell,
headerRow,
footerRow,
visibleColumns,
visibleRowData,
rows,
rowHeight,
enabledRowRenderers,
refetch,
setCustomGridBodyProps,
headerRow,
footerRow,
gridWidth,
} = props;
const { euiTheme } = useEuiTheme();
// // Set custom props onto the grid body wrapper
const bodyRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setCustomGridBodyProps({
ref: bodyRef,
style: {
width: '100%',
height: '100%',
overflowY: 'hidden',
scrollbarColor: `${euiTheme.colors.mediumShade} ${euiTheme.colors.lightestShade}`,
},
});
}, [setCustomGridBodyProps, euiTheme.colors.mediumShade, euiTheme.colors.lightestShade]);
const visibleRows = useMemo(
() => (rows ?? []).slice(visibleRowData.startRow, visibleRowData.endRow),
[rows, visibleRowData]
);
const listRef = useRef<VariableSizeList<unknown>>(null);
const rowHeights = useRef<number[]>([]);
const setRowHeight = useCallback((index: number, height: number) => {
if (rowHeights.current[index] === height) return;
listRef.current?.resetAfterIndex(index);
rowHeights.current[index] = height;
}, []);
const getRowHeight = useCallback((index: number) => {
return rowHeights.current[index] ?? 100;
}, []);
/*
*
* There is a difference between calculatedWidth & gridWidth
*
* gridWidth is the width of the grid as per the screen size
*
* calculatedWidth is the width of the grid that is calculated by EUI and represents
* the actual width of the grid based on the content of the grid. ( Sum of the width of all columns)
*
* For example, screensize can be variable but calculatedWidth can be much more than that
* with grid having a horizontal scrollbar
*
*
* */
const [calculatedWidth, setCalculatedWidth] = useState<number>(gridWidth);
useEffect(() => {
/*
* Any time gridWidth(available screen size) is changed, we need to re-check
* to see if EUI has changed the width of the grid
*
*/
if (!bodyRef) return;
const headerRowRef = bodyRef?.current?.querySelector('.euiDataGridHeader[role="row"]');
setCalculatedWidth((prev) =>
headerRowRef?.clientWidth && headerRowRef?.clientWidth !== prev
? headerRowRef?.clientWidth
: prev
);
}, [gridWidth]);
const innerRowContainer = useMemo(() => {
const InnerComp = React.forwardRef<
HTMLDivElement,
PropsWithChildren<{ style: CSSProperties }>
>(({ children, style, ...rest }, ref) => {
return (
<>
{headerRow}
<div
className="custom__grid__rows--container"
data-test-subj="customGridRowsContainer"
ref={ref}
style={{ ...style, position: 'relative' }}
{...rest}
>
{children}
</div>
{footerRow}
</>
);
});
InnerComp.displayName = 'InnerRowContainer';
return React.memo(InnerComp);
}, [headerRow, footerRow]);
return (
<>
{headerRow}
{visibleRows.map((row, rowIndex) => {
return (
<CustomDataGridSingleRow
rowData={row}
rowIndex={rowIndex}
key={rowIndex}
visibleColumns={visibleColumns}
rowHeight={rowHeight}
Cell={Cell}
enabledRowRenderers={enabledRowRenderers}
refetch={refetch}
/>
);
})}
{footerRow}
</>
<VirtualizedCustomDataGridContainer $maxWidth={calculatedWidth}>
<EuiAutoSizer className="autosizer" disableWidth>
{({ height }) => {
return (
<>
{
/**
* whenever timeline is minimized, VariableList is re-rendered which causes delay,
* so below code makes sure that grid is only rendered when gridWidth is not 0
*/
gridWidth !== 0 && (
<>
<VariableSizeList
className="variable__list"
/* available space on the screen */
width={gridWidth}
height={height}
itemCount={visibleRows.length}
itemSize={getRowHeight}
overscanCount={5}
ref={listRef}
style={SCROLLBAR_STYLE}
innerElementType={innerRowContainer}
>
{({ index, style }) => {
return (
<div
role="row"
style={{
...style,
width: 'fit-content',
}}
key={`${gridWidth}-${index}`}
>
<CustomDataGridSingleRow
rowData={visibleRows[index]}
rowIndex={index}
visibleColumns={visibleColumns}
Cell={Cell}
enabledRowRenderers={enabledRowRenderers}
refetch={refetch}
setRowHeight={setRowHeight}
rowHeight={rowHeight}
/>
</div>
);
}}
</VariableSizeList>
</>
)
}
</>
);
}}
</EuiAutoSizer>
</VirtualizedCustomDataGridContainer>
);
}
);
@ -85,41 +258,17 @@ export const CustomTimelineDataGridBody: FC<CustomTimelineDataGridBodyProps> = m
/**
*
* A Simple Wrapper component for displaying a custom grid row
* Generating CSS on this row puts a huge performance overhead on the grid as each row much styled individually.
* If possible, try to use the styles either in ../styles.tsx or in the parent component
*
*/
const CustomGridRow = styled.div.attrs<{
className?: string;
}>((props) => ({
className: `euiDataGridRow ${props.className ?? ''}`,
role: 'row',
}))`
width: fit-content;
border-bottom: 1px solid ${(props) => (props.theme as EuiTheme).eui.euiBorderThin};
. euiDataGridRowCell--controlColumn {
height: ${(props: { $cssRowHeight: string }) => props.$cssRowHeight};
min-height: ${DEFAULT_UDT_ROW_HEIGHT}px;
}
.udt--customRow {
border-radius: 0;
padding: ${(props) => (props.theme as EuiTheme).eui.euiDataGridCellPaddingM};
max-width: ${(props) => (props.theme as EuiTheme).eui.euiPageDefaultMaxWidth};
width: 85vw;
}
.euiCommentEvent__body {
background-color: ${(props) => (props.theme as EuiTheme).eui.euiColorEmptyShade};
}
&:has(.unifiedDataTable__cell--expanded) {
.euiDataGridRowCell--firstColumn,
.euiDataGridRowCell--lastColumn,
.euiDataGridRowCell--controlColumn,
.udt--customRow {
${({ theme }) => `background-color: ${theme.eui.euiColorHighlight};`}
}
}
}
`;
}))``;
/* below styles as per : https://eui.elastic.co/#/tabular-content/data-grid-advanced#custom-body-renderer */
const CustomGridRowCellWrapper = styled.div.attrs<{
@ -138,6 +287,7 @@ const CustomGridRowCellWrapper = styled.div.attrs<{
type CustomTimelineDataGridSingleRowProps = {
rowData: DataTableRecord & TimelineItem;
rowIndex: number;
setRowHeight: (index: number, height: number) => void;
} & Pick<
CustomTimelineDataGridBodyProps,
'visibleColumns' | 'Cell' | 'enabledRowRenderers' | 'refetch' | 'rowHeight'
@ -168,13 +318,24 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow(
visibleColumns,
Cell,
rowHeight: rowHeightMultiple = 0,
setRowHeight,
} = props;
const { canShowRowRenderer } = useStatefulRowRenderer({
data: rowData.ecs,
rowRenderers: enabledRowRenderers,
});
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (rowRef.current) {
setRowHeight(rowIndex, rowRef.current.offsetHeight);
}
}, [rowIndex, setRowHeight]);
const cssRowHeight: string = calculateRowHeightInPixels(rowHeightMultiple - 1);
/**
* removes the border between the actual row ( timelineEvent) and `TimelineEventDetail` row
* which renders the row-renderer, notes and notes editor
@ -194,12 +355,11 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow(
return (
<CustomGridRow
className={`${rowIndex % 2 !== 0 ? 'euiDataGridRow--striped' : ''}`}
$cssRowHeight={cssRowHeight}
key={rowIndex}
ref={rowRef}
>
<CustomGridRowCellWrapper className={eventTypeRowClassName} $cssRowHeight={cssRowHeight}>
{visibleColumns.map((column, colIndex) => {
// Skip the expanded row cell - we'll render it manually outside of the flex wrapper
if (column.id !== TIMELINE_EVENT_DETAIL_ROW_ID) {
return (
<Cell
@ -217,6 +377,9 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow(
{/* Timeline Expanded Row */}
{canShowRowRenderer ? (
<Cell
rowHeightsOptions={defaultAutoHeight}
/* @ts-expect-error because currently CellProps do not allow string width but it is important to be passed for height calculations */
width={'100%'}
colIndex={visibleColumns.length - 1} // If the row is being shown, it should always be the last index
visibleRowIndex={rowIndex}
/>

View file

@ -12,8 +12,13 @@ import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { UnifiedDataTableProps } from '@kbn/unified-data-table';
import { UnifiedDataTable, DataLoadingState } from '@kbn/unified-data-table';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { EuiDataGridCustomBodyProps, EuiDataGridProps } from '@elastic/eui';
import type {
EuiDataGridControlColumn,
EuiDataGridCustomBodyProps,
EuiDataGridProps,
} from '@elastic/eui';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { JEST_ENVIRONMENT } from '../../../../../../common/constants';
import { useOnExpandableFlyoutClose } from '../../../../../flyout/shared/hooks/use_on_expandable_flyout_close';
import { DocumentDetailsRightPanelKey } from '../../../../../flyout/document_details/shared/constants/panel_keys';
import { selectTimelineById } from '../../../../store/selectors';
@ -43,7 +48,6 @@ import { transformTimelineItemToUnifiedRows } from '../utils';
import { TimelineEventDetailRow } from './timeline_event_detail_row';
import { CustomTimelineDataGridBody } from './custom_timeline_data_grid_body';
import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants';
import type { UnifiedTimelineDataGridCellContext } from '../../types';
export const SAMPLE_SIZE_SETTING = 500;
const DataGridMemoized = React.memo(UnifiedDataTable);
@ -288,6 +292,23 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
return rowRenderers.filter((rowRenderer) => !excludedRowRendererIds.includes(rowRenderer.id));
}, [excludedRowRendererIds, rowRenderers]);
const TimelineEventDetailRowRendererComp = useMemo<EuiDataGridControlColumn['rowCellRender']>(
() =>
function TimelineEventDetailRowRenderer(props) {
const { rowIndex, ...restProps } = props;
return (
<TimelineEventDetailRow
event={tableRows[rowIndex]}
rowIndex={rowIndex}
timelineId={timelineId}
enabledRowRenderers={enabledRowRenderers}
{...restProps}
/>
);
},
[tableRows, timelineId, enabledRowRenderers]
);
/**
* Ref: https://eui.elastic.co/#/tabular-content/data-grid-advanced#custom-body-renderer
*/
@ -295,31 +316,20 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
() => [
{
id: TIMELINE_EVENT_DETAIL_ROW_ID,
// The header cell should be visually hidden, but available to screen readers
width: 0,
// The header cell should be visually hidden, but available to screen readers
headerCellRender: () => <></>,
headerCellProps: { className: 'euiScreenReaderOnly' },
// The footer cell can be hidden to both visual & SR users, as it does not contain meaningful information
footerCellProps: { style: { display: 'none' } },
// When rendering this custom cell, we'll want to override
// the automatic width/heights calculated by EuiDataGrid
rowCellRender: (props) => {
const { rowIndex, ...restProps } = props;
return (
<TimelineEventDetailRow
event={tableRows[rowIndex]}
rowIndex={rowIndex}
timelineId={timelineId}
enabledRowRenderers={enabledRowRenderers}
{...restProps}
/>
);
},
rowCellRender: JEST_ENVIRONMENT
? TimelineEventDetailRowRendererComp
: React.memo(TimelineEventDetailRowRendererComp),
},
],
[enabledRowRenderers, tableRows, timelineId]
[TimelineEventDetailRowRendererComp]
);
/**
@ -352,12 +362,6 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
[tableRows, enabledRowRenderers, rowHeight, refetch]
);
const cellContext: UnifiedTimelineDataGridCellContext = useMemo(() => {
return {
expandedEventId: expandedDoc?.id,
};
}, [expandedDoc]);
const finalRenderCustomBodyCallback = useMemo(() => {
return enabledRowRenderers.length > 0 ? renderCustomBodyCallback : undefined;
}, [enabledRowRenderers.length, renderCustomBodyCallback]);
@ -419,7 +423,6 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
renderCustomGridBody={finalRenderCustomBodyCallback}
trailingControlColumns={finalTrailControlColumns}
externalControlColumns={leadingControlColumns}
cellContext={cellContext}
/>
</StyledTimelineUnifiedDataTable>
</StatefulEventContext.Provider>

View file

@ -18,7 +18,14 @@ const mockData = structuredClone(mockTimelineData);
const setCellPropsMock = jest.fn();
jest.mock('../../body/events/stateful_row_renderer');
jest.mock('../../body/events/stateful_row_renderer', () => {
return {
StatefulRowRenderer: jest.fn(),
};
});
const StatefulRowRendererMock = StatefulRowRenderer as unknown as jest.Mock;
jest.mock('./use_timeline_unified_data_table_context');
const renderTestComponent = (props: Partial<TimelineEventDetailRowProps> = {}) => {
@ -44,7 +51,7 @@ const renderTestComponent = (props: Partial<TimelineEventDetailRowProps> = {}) =
describe('TimelineEventDetailRow', () => {
beforeEach(() => {
(StatefulRowRenderer as jest.Mock).mockReturnValue(<div>{'Test Row Renderer'}</div>);
StatefulRowRendererMock.mockReturnValue(<div>{'Test Row Renderer'}</div>);
(useTimelineUnifiedDataTableContext as jest.Mock).mockReturnValue({
expanded: { id: undefined },
@ -60,7 +67,7 @@ describe('TimelineEventDetailRow', () => {
expect(setCellPropsMock).toHaveBeenCalledWith({
className: '',
style: { width: '100%', height: 'auto' },
style: { width: '100%', height: undefined, overflowX: 'auto' },
});
expect(getByText('Test Row Renderer')).toBeVisible();
@ -82,7 +89,7 @@ describe('TimelineEventDetailRow', () => {
expect(setCellPropsMock).toHaveBeenCalledWith({
className: 'unifiedDataTable__cell--expanded',
style: { width: '100%', height: 'auto' },
style: { width: '100%', height: undefined, overflowX: 'auto' },
});
});
});

View file

@ -60,7 +60,7 @@ export const TimelineEventDetailRow: React.FC<TimelineEventDetailRowProps> = mem
useEffect(() => {
setCellProps?.({
className: ctx.expanded?.id === event._id ? 'unifiedDataTable__cell--expanded' : '',
style: { width: '100%', height: 'auto' },
style: { width: '100%', height: undefined, overflowX: 'auto' },
});
}, [ctx.expanded?.id, setCellProps, rowIndex, event._id]);
@ -72,7 +72,7 @@ export const TimelineEventDetailRow: React.FC<TimelineEventDetailRowProps> = mem
alignItems="center"
data-test-subj={`timeline-row-renderer-${rowIndex}`}
>
<EuiFlexItem grow={false}>
<EuiFlexItem grow={true}>
<EventsTrSupplement>
<StatefulRowRenderer
ariaRowindex={rowIndex + ARIA_ROW_INDEX_OFFSET}

View file

@ -66,6 +66,11 @@ export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = ''
className: `unifiedDataTable ${className}`,
role: 'rowgroup',
}))`
.udtTimeline .euiDataGrid__virtualized {
${({ theme }) =>
`scrollbar-color: ${theme.eui.euiColorMediumShade} ${theme.eui.euiColorLightShade}`};
}
.udtTimeline [data-gridcell-column-id|='select'] {
border-right: none;
}
@ -182,6 +187,10 @@ export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = ''
align-items: baseline;
}
.euiDataGrid__customRenderBody {
scrollbar-color: transparent !important;
}
${leadingActionsColumnStyles}
`;

View file

@ -503,8 +503,6 @@ describe('indicator match', { tags: ['@ess', '@serverless', '@skipInServerlessMK
});
it('Investigate alert in timeline', () => {
const accessibilityText = `Press enter for options, or press space to begin dragging.`;
loadPrepackagedTimelineTemplates();
createRule(getNewThreatIndicatorRule({ rule_id: 'rule_testing', enabled: true })).then(
(rule) => visitRuleDetailsPage(rule.body.id)
@ -525,14 +523,9 @@ describe('indicator match', { tags: ['@ess', '@serverless', '@skipInServerlessMK
cy.get(INDICATOR_MATCH_ROW_RENDER).should(
'have.text',
`threat.enrichments.matched.field${
getNewThreatIndicatorRule().threat_mapping[0].entries[0].field
}${accessibilityText}matched${
getNewThreatIndicatorRule().threat_mapping[0].entries[0].field
}${
`${getNewThreatIndicatorRule().threat_mapping[0].entries[0].field}matched${
indicatorRuleMatchingDoc.atomic
}${accessibilityText}threat.enrichments.matched.typeindicator_match_rule${accessibilityText}provided` +
` byfeed.nameAbuseCH malware${accessibilityText}`
}indicator_match_ruleprovided` + ` byAbuseCH malware`
);
});
});

View file

@ -11316,10 +11316,10 @@
dependencies:
"@types/react" "*"
"@types/react-virtualized@^9.21.22":
version "9.21.22"
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.22.tgz#5ba39b29869200620a6bf2069b8393f258a9c1e2"
integrity sha512-YRifyCKnBG84+J/Hny0f3bo8BRrcNT74CvsAVpQpZcS83fdC7lP7RfzwL2ND8/ihhpnDFL1IbxJ9MpQNaKUDuQ==
"@types/react-virtualized@^9.21.30":
version "9.21.30"
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.30.tgz#ba39821bcb2487512a8a2cdd9fbdb5e6fc87fedb"
integrity sha512-4l2TFLQ8BCjNDQlvH85tU6gctuZoEdgYzENQyZHpgTHU7hoLzYgPSOALMAeA58LOWua8AzC6wBivPj1lfl6JgQ==
dependencies:
"@types/prop-types" "*"
"@types/react" "*"