[Performance][Security Solution] - Improve cell renderer performance (#212982)

## Summary

Background: https://github.com/elastic/kibana/pull/212173

Based off of feedback on the work in the PRs listed in that issue,
additional performance improvements can be made to the cells rendered in
the alert table. The changes made in this PR involve migrating out
shared context to a provider so certain hooks (some expensive... i.e.
browserFieldsByName) aren't made for every cell in the UI, but once and
passed down to each cell accordingly.


- [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
This commit is contained in:
Michael Olorunnisola 2025-03-06 08:35:04 -05:00 committed by GitHub
parent ae74cc35a9
commit 4db40eacde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 209 additions and 109 deletions

View file

@ -14,6 +14,7 @@ import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { DataViewBase } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import { TableId } from '@kbn/securitysolution-data-table';
import { AlertTableCellContextProvider } from '../../../../detections/configurations/security_solution_detections/cell_value_context';
import { StatefulEventsViewer } from '../../../../common/components/events_viewer';
import { defaultRowRenderers } from '../../../../timelines/components/timeline/body/renderers';
import * as i18n from './translations';
@ -142,7 +143,10 @@ const PreviewHistogramComponent = ({
}, [config, indexPattern, previewId]);
return (
<>
<AlertTableCellContextProvider
tableId={TableId.rulePreview}
sourcererScope={SourcererScopeName.detections}
>
<Panel height={DEFAULT_HISTOGRAM_HEIGHT} data-test-subj={'preview-histogram-panel'}>
<EuiFlexGroup gutterSize="none" direction="column">
<EuiFlexItem grow={1}>
@ -199,7 +203,7 @@ const PreviewHistogramComponent = ({
bulkActions={false}
/>
</FullScreenContainer>
</>
</AlertTableCellContextProvider>
);
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useMemo } from 'react';
import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
import { TableId } from '@kbn/securitysolution-data-table';
import type { LegacyField } from '@kbn/alerting-types';
@ -13,6 +13,8 @@ import type { CellValueElementProps } from '../../../../../common/types';
import { SourcererScopeName } from '../../../../sourcerer/store/model';
import { CellValue } from '../../../../detections/configurations/security_solution_detections';
const emptyUserProfiles = { profiles: [], isLoading: false };
export const PreviewRenderCellValue: React.FC<
EuiDataGridCellValueElementProps & CellValueElementProps
> = ({
@ -28,11 +30,12 @@ export const PreviewRenderCellValue: React.FC<
rowRenderers,
truncate,
}) => {
const legacyAlert = useMemo(() => (data ?? []) as LegacyField[], [data]);
return (
<CellValue
tableType={TableId.rulePreview}
sourcererScope={SourcererScopeName.detections}
legacyAlert={(data ?? []) as LegacyField[]}
legacyAlert={legacyAlert}
ecsAlert={ecsData}
asPlainText={true}
setCellProps={setCellProps}
@ -44,7 +47,7 @@ export const PreviewRenderCellValue: React.FC<
columnId={columnId}
rowRenderers={rowRenderers}
truncate={truncate}
userProfiles={{ profiles: [], isLoading: false }}
userProfiles={emptyUserProfiles}
/>
);
};

View file

@ -73,6 +73,7 @@ import { AdditionalToolbarControls } from './additional_toolbar_controls';
import { useFetchUserProfilesFromAlerts } from '../../configurations/security_solution_detections/fetch_page_context';
import { useCellActionsOptions } from '../../hooks/trigger_actions_alert_table/use_cell_actions';
import { useAlertsTableFieldsBrowserOptions } from '../../hooks/trigger_actions_alert_table/use_trigger_actions_browser_fields_options';
import { AlertTableCellContextProvider } from '../../configurations/security_solution_detections/cell_value_context';
const { updateIsLoading, updateTotalCount } = dataTableActions;
@ -172,9 +173,10 @@ const DetectionEngineAlertsTableComponent: FC<Omit<DetectionEngineAlertTableProp
const dispatch = useDispatch();
const timelineID = tableType;
// Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created
const [activeStatefulEventContext] = useState({
timelineID: tableType,
timelineID,
tabType: 'query',
enableHostDetailsFlyout: true,
enableIpDetailsFlyout: true,
@ -454,44 +456,49 @@ const DetectionEngineAlertsTableComponent: FC<Omit<DetectionEngineAlertTableProp
<FullWidthFlexGroupTable $visible={!graphEventId && graphOverlay == null} gutterSize="none">
<StatefulEventContext.Provider value={activeStatefulEventContext}>
<EuiDataGridContainer hideLastPage={false}>
<AlertsTable<SecurityAlertsTableContext>
ref={alertsTableRef}
// Stores separate configuration based on the view of the table
id={id ?? `detection-engine-alert-table-${tableType}-${tableView}`}
ruleTypeIds={SECURITY_SOLUTION_RULE_TYPE_IDS}
consumers={ALERT_TABLE_CONSUMERS}
query={finalBoolQuery}
initialSort={initialSort}
casesConfiguration={casesConfiguration}
gridStyle={gridStyle}
shouldHighlightRow={shouldHighlightRow}
rowHeightsOptions={rowHeightsOptions}
columns={finalColumns}
browserFields={finalBrowserFields}
onUpdate={onUpdate}
additionalContext={additionalContext}
height={alertTableHeight}
initialPageSize={50}
runtimeMappings={sourcererDataView?.runtimeFieldMap as RunTimeMappings}
toolbarVisibility={toolbarVisibility}
renderCellValue={CellValue}
renderActionsCell={ActionsCell}
renderAdditionalToolbarControls={
tableType !== TableId.alertsOnCasePage ? AdditionalToolbarControls : undefined
}
actionsColumnWidth={leadingControlColumn.width}
getBulkActions={getBulkActions}
fieldsBrowserOptions={
tableType === TableId.alertsOnAlertsPage ||
tableType === TableId.alertsOnRuleDetailsPage
? fieldsBrowserOptions
: undefined
}
cellActionsOptions={cellActionsOptions}
showInspectButton
services={services}
{...tablePropsOverrides}
/>
<AlertTableCellContextProvider
tableId={tableType}
sourcererScope={SourcererScopeName.detections}
>
<AlertsTable<SecurityAlertsTableContext>
ref={alertsTableRef}
// Stores separate configuration based on the view of the table
id={id ?? `detection-engine-alert-table-${tableType}-${tableView}`}
ruleTypeIds={SECURITY_SOLUTION_RULE_TYPE_IDS}
consumers={ALERT_TABLE_CONSUMERS}
query={finalBoolQuery}
initialSort={initialSort}
casesConfiguration={casesConfiguration}
gridStyle={gridStyle}
shouldHighlightRow={shouldHighlightRow}
rowHeightsOptions={rowHeightsOptions}
columns={finalColumns}
browserFields={finalBrowserFields}
onUpdate={onUpdate}
additionalContext={additionalContext}
height={alertTableHeight}
initialPageSize={50}
runtimeMappings={sourcererDataView?.runtimeFieldMap as RunTimeMappings}
toolbarVisibility={toolbarVisibility}
renderCellValue={CellValue}
renderActionsCell={ActionsCell}
renderAdditionalToolbarControls={
tableType !== TableId.alertsOnCasePage ? AdditionalToolbarControls : undefined
}
actionsColumnWidth={leadingControlColumn.width}
getBulkActions={getBulkActions}
fieldsBrowserOptions={
tableType === TableId.alertsOnAlertsPage ||
tableType === TableId.alertsOnRuleDetailsPage
? fieldsBrowserOptions
: undefined
}
cellActionsOptions={cellActionsOptions}
showInspectButton
services={services}
{...tablePropsOverrides}
/>
</AlertTableCellContextProvider>
</EuiDataGridContainer>
</StatefulEventContext.Provider>
</FullWidthFlexGroupTable>

View file

@ -0,0 +1,66 @@
/*
* 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, { createContext, useMemo } from 'react';
import type { FieldSpec } from '@kbn/data-views-plugin/common';
import { tableDefaults, dataTableSelectors } from '@kbn/securitysolution-data-table';
import type { BrowserFields } from '@kbn/timelines-plugin/common';
import type { SourcererScopeName } from '../../../sourcerer/store/model';
import { useLicense } from '../../../common/hooks/use_license';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useSourcererDataView } from '../../../sourcerer/containers';
import { VIEW_SELECTION } from '../../../../common/constants';
import { getAllFieldsByName } from '../../../common/containers/source';
import { eventRenderedViewColumns, getColumns } from './columns';
import type { AlertColumnHeaders } from './columns';
interface AlertTableCellContextProps {
browserFields: BrowserFields;
browserFieldsByName: Record<string, Partial<FieldSpec>>;
columnHeaders: AlertColumnHeaders;
}
export const AlertTableCellContext = createContext<AlertTableCellContextProps | null>(null);
export const AlertTableCellContextProvider = ({
tableId = '',
sourcererScope,
children,
}: {
tableId?: string;
sourcererScope: SourcererScopeName;
children: React.ReactNode;
}) => {
const { browserFields } = useSourcererDataView(sourcererScope);
const browserFieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]);
const license = useLicense();
const gridColumns = useMemo(() => {
return getColumns(license);
}, [license]);
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
const viewMode =
useDeepEqualSelector((state) => (getTable(state, tableId ?? '') ?? tableDefaults).viewMode) ??
tableDefaults.viewMode;
const columnHeaders = useMemo(() => {
return viewMode === VIEW_SELECTION.gridView ? gridColumns : eventRenderedViewColumns;
}, [gridColumns, viewMode]);
const cellValueContext = useMemo<AlertTableCellContextProps>(
() => ({
browserFields,
browserFieldsByName,
columnHeaders,
}),
[browserFields, browserFieldsByName, columnHeaders]
);
return (
<AlertTableCellContext.Provider value={cellValueContext}>
{children}
</AlertTableCellContext.Provider>
);
};

View file

@ -137,11 +137,10 @@ const getBaseColumns = (
* columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface,
* plus additional TGrid column properties
*/
export const getColumns = (
license?: LicenseService
): Array<
export type AlertColumnHeaders = Array<
Pick<EuiDataGridColumn, 'display' | 'displayAsText' | 'id' | 'initialWidth'> & ColumnHeaderOptions
> => [
>;
export const getColumns = (license?: LicenseService): AlertColumnHeaders => [
{
columnHeaderType: defaultColumnHeaderType,
id: '@timestamp',

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { mount } from 'enzyme';
import { render } from '@testing-library/react';
import { cloneDeep } from 'lodash/fp';
import type { ComponentProps } from 'react';
import React from 'react';
@ -16,9 +16,10 @@ import { DragDropContextWrapper } from '../../../common/components/drag_and_drop
import { defaultHeaders, mockTimelineData, TestProviders } from '../../../common/mock';
import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
import type { TimelineNonEcsData } from '../../../../common/search_strategy/timeline';
import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer';
import type { RenderCellValueProps } from './render_cell_value';
import { CellValue } from './render_cell_value';
import { SourcererScopeName } from '../../../sourcerer/store/model';
import { AlertTableCellContextProvider } from './cell_value_context';
jest.mock('../../../common/lib/kibana');
jest.mock('../../../sourcerer/containers', () => ({
@ -41,12 +42,12 @@ describe('RenderCellValue', () => {
let data: TimelineNonEcsData[];
let header: ColumnHeaderOptions;
let props: ComponentProps<typeof CellValue>;
let defaultProps: RenderCellValueProps;
beforeEach(() => {
data = cloneDeep(mockTimelineData[0].data);
header = cloneDeep(defaultHeaders[0]);
props = {
defaultProps = {
columnId,
legacyAlert: data,
eventId,
@ -68,37 +69,54 @@ describe('RenderCellValue', () => {
} as unknown as ComponentProps<typeof CellValue>;
});
test('it forwards the `CellValueElementProps` to the `DefaultCellRenderer`', () => {
const wrapper = mount(
const RenderCellValueComponent = (props: RenderCellValueProps) => {
return (
<TestProviders>
<DragDropContextWrapper browserFields={mockBrowserFields}>
<CellValue
{...props}
sourcererScope={SourcererScopeName.default}
tableType={TableId.test}
/>
<AlertTableCellContextProvider
tableId={TableId.test}
sourcererScope={SourcererScopeName.detections}
>
<CellValue
{...defaultProps}
{...props}
sourcererScope={SourcererScopeName.detections}
tableType={TableId.test}
/>
</AlertTableCellContextProvider>
</DragDropContextWrapper>
</TestProviders>
);
};
const { legacyAlert, ...defaultCellRendererProps } = props;
it('should throw an error if not wrapped by the AlertTableCellContextProvider', () => {
const renderWithError = () =>
render(
<TestProviders>
<DragDropContextWrapper browserFields={mockBrowserFields}>
<CellValue
{...defaultProps}
sourcererScope={SourcererScopeName.detections}
tableType={TableId.test}
/>
</DragDropContextWrapper>
</TestProviders>
);
expect(wrapper.find(DefaultCellRenderer).props()).toEqual({
...defaultCellRendererProps,
data: legacyAlert,
scopeId: SourcererScopeName.default,
});
expect(renderWithError).toThrow(
'render_cell_value.tsx: CellValue must be used within AlertTableCellContextProvider'
);
});
test('it renders a GuidedOnboardingTourStep', () => {
const wrapper = mount(
<TestProviders>
<DragDropContextWrapper browserFields={mockBrowserFields}>
<CellValue {...props} scopeId={SourcererScopeName.default} tableType={TableId.test} />
</DragDropContextWrapper>
</TestProviders>
);
it('should fully render the cell value', () => {
const { getByText } = render(<RenderCellValueComponent {...defaultProps} />);
expect(wrapper.find('[data-test-subj="GuidedOnboardingTourStep"]').exists()).toEqual(true);
expect(getByText('Nov 5, 2018 @ 19:03:25.937')).toBeInTheDocument();
});
it('should render the guided onboarding step', () => {
const { getByTestId } = render(<RenderCellValueComponent {...defaultProps} />);
expect(getByTestId('GuidedOnboardingTourStep')).toBeInTheDocument();
});
});

View file

@ -5,13 +5,11 @@
* 2.0.
*/
import React, { useMemo, memo, type ComponentProps } from 'react';
import React, { useMemo, memo, type ComponentProps, useContext } from 'react';
import { EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { find, getOr } from 'lodash/fp';
import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
import { tableDefaults, dataTableSelectors } from '@kbn/securitysolution-data-table';
import { useLicense } from '../../../common/hooks/use_license';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useKibana } from '../../../common/lib/kibana';
import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step';
import { isDetectionsAlertsTable } from '../../../common/components/top_n/helpers';
@ -20,15 +18,12 @@ import {
SecurityStepId,
} from '../../../common/components/guided_onboarding_tour/tour_config';
import { SIGNAL_RULE_NAME_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants';
import { useSourcererDataView } from '../../../sourcerer/containers';
import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer';
import { SUPPRESSED_ALERT_TOOLTIP } from './translations';
import { VIEW_SELECTION } from '../../../../common/constants';
import { getAllFieldsByName } from '../../../common/containers/source';
import { eventRenderedViewColumns, getColumns } from './columns';
import type { GetSecurityAlertsTableProp } from '../../components/alerts_table/types';
import type { CellValueElementProps, ColumnHeaderOptions } from '../../../../common/types';
import { AlertTableCellContext } from './cell_value_context';
/**
* This implementation of `EuiDataGrid`'s `renderCellValue`
@ -36,7 +31,7 @@ import type { CellValueElementProps, ColumnHeaderOptions } from '../../../../com
* from the TGrid
*/
type RenderCellValueProps = Pick<
export type RenderCellValueProps = Pick<
ComponentProps<GetSecurityAlertsTableProp<'renderCellValue'>>,
| 'columnId'
| 'rowIndex'
@ -76,6 +71,7 @@ export const CellValue = memo(function RenderCellValue({
truncate,
userProfiles,
}: RenderCellValueProps) {
const { notifications } = useKibana().services;
const isTourAnchor = useMemo(
() =>
columnId === SIGNAL_RULE_NAME_FIELD_NAME &&
@ -84,22 +80,21 @@ export const CellValue = memo(function RenderCellValue({
!isDetails,
[columnId, isDetails, rowIndex, tableType]
);
const { browserFields } = useSourcererDataView(sourcererScope);
const browserFieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]);
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
const license = useLicense();
const viewMode =
useDeepEqualSelector((state) => (getTable(state, tableId ?? '') ?? tableDefaults).viewMode) ??
tableDefaults.viewMode;
const cellValueContext = useContext(AlertTableCellContext);
const gridColumns = useMemo(() => {
return getColumns(license);
}, [license]);
if (!cellValueContext) {
const contextMissingError = new Error(
'render_cell_value.tsx: CellValue must be used within AlertTableCellContextProvider'
);
const columnHeaders = useMemo(() => {
return viewMode === VIEW_SELECTION.gridView ? gridColumns : eventRenderedViewColumns;
}, [gridColumns, viewMode]);
notifications.toasts.addError(contextMissingError, {
title: 'AlertTableCellContextProvider is missing',
toastMessage: 'CellValue must be used within AlertTableCellContextProvider',
});
throw new Error(contextMissingError.message);
}
const { browserFields, browserFieldsByName, columnHeaders } = cellValueContext;
/**
* There is difference between how `triggers actions` fetched data v/s
* how security solution fetches data via timelineSearchStrategy
@ -134,11 +129,21 @@ export const CellValue = memo(function RenderCellValue({
return ecsSuppressionCount ? parseInt(ecsSuppressionCount, 10) : dataSuppressionCount;
}, [ecsAlert, legacyAlert]);
const Renderer = useMemo(() => {
const myHeader =
header ?? ({ id: columnId, ...browserFieldsByName[columnId] } as ColumnHeaderOptions);
const colHeader = columnHeaders.find((col) => col.id === columnId);
const localLinkValues = getOr([], colHeader?.linkField ?? '', ecsAlert);
const myHeader = useMemo(
() => header ?? ({ id: columnId, ...browserFieldsByName[columnId] } as ColumnHeaderOptions),
[browserFieldsByName, columnId, header]
);
const colHeader = useMemo(
() => columnHeaders.find((col) => col.id === columnId),
[columnHeaders, columnId]
);
const localLinkValues = useMemo(
() => getOr([], colHeader?.linkField ?? '', ecsAlert),
[colHeader?.linkField, ecsAlert]
);
const CellRenderer = useMemo(() => {
return (
<GuidedOnboardingTourStep
isTourAnchor={isTourAnchor}
@ -168,20 +173,18 @@ export const CellValue = memo(function RenderCellValue({
</GuidedOnboardingTourStep>
);
}, [
header,
columnId,
browserFieldsByName,
columnHeaders,
ecsAlert,
isTourAnchor,
browserFields,
columnId,
finalData,
ecsAlert,
eventId,
myHeader,
isDetails,
isExpandable,
isExpanded,
linkValues,
localLinkValues,
rowIndex,
colIndex,
rowRenderers,
@ -198,9 +201,9 @@ export const CellValue = memo(function RenderCellValue({
<EuiIcon type="layers" />
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>{Renderer}</EuiFlexItem>
<EuiFlexItem grow={false}>{CellRenderer}</EuiFlexItem>
</EuiFlexGroup>
) : (
<>{Renderer}</>
<>{CellRenderer}</>
);
});