[Security Solution] Fix notes selectors, and alerts table onload, other reselect related issues (#213609)

## Summary

This pr fixes a few very different but compounding upon one another
issues with how redux/reselect is currently being used across a range of
places in the security app. To start, I will focus on the notes
selectors.

The first issue was our use of filter. filter creates a shallow copy of
an array every time it runs, even if nothing matches, creating a new
reference quite often, even just in the flyout with 0 notes. This is
explicitly mentioned in the docs:
https://reselect.js.org/usage/handling-empty-array-results . Using a
static reference if filter returns 0 items with the fallbackToEmptyArray
reduces a lot of references changing everywhere notes selectors are
used, especially in the alerts table, which I will get to in a bit. The
next issue was with how we were extracting arguments, namely this
pattern:

```
createSelector(
  [
    selectAllNotes,
    (_: State, { documentId, savedObjectId }: { documentId: string; savedObjectId: string }) => ({
      documentId,
      savedObjectId,
    }),
  ],
  (notes, { documentId, savedObjectId }) =>
    fallbackToEmptyArray(
      notes.filter((note) => note.eventId === documentId && note.timelineId === savedObjectId)
    )
);
```
will not actually work as expected, because the 2nd input selector
function is creating a new object reference every time it's called, All
arguments are actually passed to all input selectors, so changing this
to something like
```
  createSelector(
    [
      selectAllNotes,
      (_: State, documentId: string) => documentId,
      (_: State, documentId: string, savedObjectId: string) => savedObjectId,
    ],
    (notes, documentId, savedObjectId) =>
      fallbackToEmptyArray(
        notes.filter((note) => note.eventId === documentId && note.timelineId === savedObjectId)
      )
  );
```
where documentId and savedObjectId are both primitives, will work as
expected...if you are expecting to only call it with a small number of
documentIds. Would work well with just the flyout, but when using it
with 50 different doc ids in the alerts table, and a different set of 50
in the timeline, not only is the memoization doing nothing, we are
looping over all the document ids x 100 constantly, rough on the app
performance, less so with very few notes, but would scale terribly. To
fix this, we use the selector creator pattern in the components, like
action cells in the table, where there would be lots of different
document ids, that don't change through the life of the component.
```
  const selectNotesByDocumentId = useMemo(() => makeSelectNotesByDocumentId(), []);
  const documentBasedNotes = useSelector((state: State) => selectNotesByDocumentId(state, eventId));
```

The final issue was the sort, we were creating a new object reference
every time, which was a problem, but also didn't actually make use of
sorting on different fields, just always on created. I changed the state
logic for this a bit, we fetch the notes from the api on the management
page just as before, only now we use an optional argument to
redux-toolkit's createEntityAdapter that allows us to sort the notes
whenever the reducer runs. If the user changes the sort direction, a
different selector runs that reverses the notes, and renders those in
the table. selectAllReversed, it works exactly as the built in
selectAll, just makes use of .reverse().

Also changed some things in the recently alerts table package changes
that were not referentially stable, fixed a useEffect that was calling
the useFetchNotes logic way too often in each tab of the timeline, and
cleaned up various selectors, some of which were completely unneeded in
the first place, or didn't work (read: didn't actually memoize anything)
at all. Finally, we fetch the notes for the alerts table correctly once
again, that is only when the data fetching query completes and is
successful, for a while now it's been running way too often, in onUpdate
instead of onLoaded


### Checklist

- [ ] [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:
Kevin Qualters 2025-03-10 16:50:44 -04:00 committed by GitHub
parent eed986162b
commit 61327446e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 169 additions and 518 deletions

View file

@ -12,6 +12,7 @@ import {
DataTableComponent,
defaultHeaders,
getEventIdToDataMapping,
getTableByIdSelector,
} from '@kbn/securitysolution-data-table';
import { AlertConsumers } from '@kbn/rule-data-utils';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
@ -43,7 +44,6 @@ import type { RowRenderer, SortColumnTimeline as Sort } from '../../../../common
import { InputsModelId } from '../../store/inputs/constants';
import type { State } from '../../store';
import { inputsActions } from '../../store/actions';
import { eventsViewerSelector } from './selectors';
import type { SourcererScopeName } from '../../../sourcerer/store/model';
import { useSourcererDataView } from '../../../sourcerer/containers';
import type { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering';
@ -65,6 +65,7 @@ import { useAlertBulkActions } from './use_alert_bulk_actions';
import type { BulkActionsProp } from '../toolbar/bulk_actions/types';
import { StatefulEventContext } from './stateful_event_context';
import { defaultUnit } from '../toolbar/unit';
import { globalFiltersQuerySelector, globalQuerySelector } from '../../store/inputs/selectors';
import { useGetFieldSpec } from '../../hooks/use_get_field_spec';
const SECURITY_ALERTS_CONSUMERS = [AlertConsumers.SIEM];
@ -114,28 +115,29 @@ const StatefulEventsViewerComponent: React.FC<EventsViewerProps & PropsFromRedux
const dispatch = useDispatch();
const theme: EuiTheme = useContext(ThemeContext);
const tableContext = useMemo(() => ({ tableId }), [tableId]);
const selectGlobalFiltersQuerySelector = useMemo(() => globalFiltersQuerySelector(), []);
const selectGlobalQuerySelector = useMemo(() => globalQuerySelector(), []);
const filters = useSelector(selectGlobalFiltersQuerySelector);
const query = useSelector(selectGlobalQuerySelector);
const selectTableById = useMemo(() => getTableByIdSelector(), []);
const {
filters,
query,
dataTable: {
columns,
defaultColumns,
deletedEventIds,
graphEventId, // If truthy, the graph viewer (Resolver) is showing
itemsPerPage,
itemsPerPageOptions,
sessionViewConfig,
showCheckboxes,
sort,
queryFields,
selectAll,
selectedEventIds,
isSelectAllChecked,
loadingEventIds,
title,
} = defaultModel,
} = useSelector((state: State) => eventsViewerSelector(state, tableId));
columns,
defaultColumns,
deletedEventIds,
graphEventId, // If truthy, the graph viewer (Resolver) is showing
itemsPerPage,
itemsPerPageOptions,
sessionViewConfig,
showCheckboxes,
sort,
queryFields,
selectAll,
selectedEventIds,
isSelectAllChecked,
loadingEventIds,
title,
} = useSelector((state: State) => selectTableById(state, tableId) ?? defaultModel);
const inspectModalTitle = useMemo(() => <span data-test-subj="title">{title}</span>, [title]);
const { uiSettings, data } = useKibana().services;

View file

@ -1,27 +0,0 @@
/*
* 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 { mockState } from './mock_state';
import { eventsViewerSelector } from '.';
describe('selectors', () => {
describe('eventsViewerSelector', () => {
it('returns the expected results', () => {
const id = 'alerts-page';
expect(eventsViewerSelector(mockState, id)).toEqual({
filters: mockState.inputs.global.filters,
input: mockState.inputs.timeline,
query: mockState.inputs.global.query,
globalQueries: mockState.inputs.global.queries,
timelineQuery: mockState.inputs.timeline.queries[0],
dataTable: mockState.dataTable.tableById[id],
});
});
});
});

View file

@ -1,48 +0,0 @@
/*
* 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 { getTableByIdSelector } from '@kbn/securitysolution-data-table';
import { createSelector } from 'reselect';
import {
getTimelineSelector,
globalFiltersQuerySelector,
globalQuery,
globalQuerySelector,
timelineQueryByIdSelector,
} from '../../../store/inputs/selectors';
/**
* This selector is invoked with two arguments:
* @param state - the state of the store as defined by State in common/store/types.ts
* @param id - a timeline id e.g. `alerts-page`
*
* Example:
* `useSelector((state: State) => eventsViewerSelector(state, id))`
*/
export const eventsViewerSelector = createSelector(
globalFiltersQuerySelector(),
getTimelineSelector(),
globalQuerySelector(),
globalQuery(),
timelineQueryByIdSelector(),
getTableByIdSelector(),
(filters, input, query, globalQueries, timelineQuery, dataTable) => ({
/** an array representing filters added to the search bar */
filters,
/** an object containing the timerange set in the global date picker, and other page level state */
input,
/** a serialized representation of the KQL / Lucence query in the search bar */
query,
/** an array of objects with metadata and actions related to queries on the page */
globalQueries,
/** an object with metadata and actions related to the table query */
timelineQuery,
/** a specific data table from the state's tableById collection, or undefined */
dataTable,
})
);

View file

@ -1,246 +0,0 @@
/*
* 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 { set } from '@kbn/safer-lodash-set';
import { pipe } from 'lodash/fp';
import { InputsModelId } from '../../../store/inputs/constants';
import { mockGlobalState } from '../../../mock';
const filters = [
{
meta: {
alias: null,
negate: false,
disabled: false,
type: 'phrase',
key: 'kibana.alert.severity',
params: { query: 'low' },
},
$state: { store: 'appState' },
query: { match_phrase: { 'kibana.alert.severity': { query: 'low' } } },
},
];
const input = {
timerange: {
kind: 'relative',
fromStr: 'now/d',
toStr: 'now/d',
from: '2022-03-17T06:00:00.000Z',
to: '2022-03-18T05:59:59.999Z',
},
queries: [
{
id: 'timeline-1-eql',
inspect: { dsl: [], response: [] },
isInspected: false,
loading: false,
selectedInspectIndex: 0,
},
],
policy: { kind: 'manual', duration: 300000 },
linkTo: [InputsModelId.global],
query: { query: '', language: 'kuery' },
filters: [],
fullScreen: false,
};
const query = { query: 'host.ip: *', language: 'kuery' };
const globalQueries = [
{
id: 'alerts-page',
inspect: {
dsl: ['{\n "allow_no_indices": ...}'],
},
isInspected: false,
loading: false,
selectedInspectIndex: 0,
},
{
id: 'detections-alerts-count-3e92347c-7e0c-44dc-a13d-fbe71706a0f0',
inspect: {
dsl: ['{\n "index": [\n ...}'],
response: ['{\n "took": 3,\n ...}'],
},
isInspected: false,
loading: false,
selectedInspectIndex: 0,
},
{
id: 'detections-histogram-a9c910ff-bcc9-48f2-9224-fad55cd5fd31',
inspect: {
dsl: ['{\n "index": [\n ...}'],
response: ['{\n "took": 4,\n ...}'],
},
isInspected: false,
loading: false,
selectedInspectIndex: 0,
},
];
const timelineQueries = [
{
id: 'alerts-page',
inspect: {
dsl: ['{\n "allow_no_indices": ...}'],
},
isInspected: false,
loading: false,
selectedInspectIndex: 0,
},
];
const timeline = {
id: 'alerts-page',
columns: [
{ columnHeaderType: 'not-filtered', id: '@timestamp', initialWidth: 200 },
{
columnHeaderType: 'not-filtered',
displayAsText: 'Rule',
id: 'kibana.alert.rule.name',
initialWidth: 180,
linkField: 'kibana.alert.rule.uuid',
},
{
columnHeaderType: 'not-filtered',
displayAsText: 'Severity',
id: 'kibana.alert.severity',
initialWidth: 105,
},
{
columnHeaderType: 'not-filtered',
displayAsText: 'Risk Score',
id: 'kibana.alert.risk_score',
initialWidth: 100,
},
{
columnHeaderType: 'not-filtered',
displayAsText: 'Reason',
id: 'kibana.alert.reason',
initialWidth: 450,
},
{ columnHeaderType: 'not-filtered', id: 'host.name' },
{ columnHeaderType: 'not-filtered', id: 'user.name' },
{ columnHeaderType: 'not-filtered', id: 'process.name' },
{ columnHeaderType: 'not-filtered', id: 'file.name' },
{ columnHeaderType: 'not-filtered', id: 'source.ip' },
{ columnHeaderType: 'not-filtered', id: 'destination.ip' },
],
defaultColumns: [
{ columnHeaderType: 'not-filtered', id: '@timestamp', initialWidth: 200 },
{
columnHeaderType: 'not-filtered',
displayAsText: 'Rule',
id: 'kibana.alert.rule.name',
initialWidth: 180,
linkField: 'kibana.alert.rule.uuid',
},
{
columnHeaderType: 'not-filtered',
displayAsText: 'Severity',
id: 'kibana.alert.severity',
initialWidth: 105,
},
{
columnHeaderType: 'not-filtered',
displayAsText: 'Risk Score',
id: 'kibana.alert.risk_score',
initialWidth: 100,
},
{
columnHeaderType: 'not-filtered',
displayAsText: 'Reason',
id: 'kibana.alert.reason',
initialWidth: 450,
},
{ columnHeaderType: 'not-filtered', id: 'host.name' },
{ columnHeaderType: 'not-filtered', id: 'user.name' },
{ columnHeaderType: 'not-filtered', id: 'process.name' },
{ columnHeaderType: 'not-filtered', id: 'file.name' },
{ columnHeaderType: 'not-filtered', id: 'source.ip' },
{ columnHeaderType: 'not-filtered', id: 'destination.ip' },
],
dataViewId: 'security-solution-default',
dateRange: { start: '2022-03-17T06:00:00.000Z', end: '2022-03-18T05:59:59.999Z' },
deletedEventIds: [],
excludedRowRendererIds: [
'alerts',
'auditd',
'auditd_file',
'library',
'netflow',
'plain',
'registry',
'suricata',
'system',
'system_dns',
'system_endgame_process',
'system_file',
'system_fim',
'system_security_event',
'system_socket',
'threat_match',
'zeek',
],
filters: [],
kqlQuery: { filterQuery: null },
indexNames: ['.alerts-security.alerts-default'],
isSelectAllChecked: false,
itemsPerPage: 25,
itemsPerPageOptions: [10, 25, 50, 100],
loadingEventIds: [],
selectedEventIds: {},
showCheckboxes: true,
sort: [{ columnId: '@timestamp', columnType: 'date', sortDirection: 'desc' }],
savedObjectId: null,
version: null,
title: '',
initialized: true,
activeTab: 'query',
prevActiveTab: 'query',
dataProviders: [],
description: '',
eqlOptions: {
eventCategoryField: 'event.category',
tiebreakerField: '',
timestampField: '@timestamp',
query: '',
size: 100,
},
eventType: 'all',
eventIdToNoteIds: {},
highlightedDropAndProviderId: '',
historyIds: [],
isFavorite: false,
isLive: false,
isSaving: false,
kqlMode: 'filter',
timelineType: 'default',
templateTimelineId: null,
templateTimelineVersion: null,
noteIds: [],
pinnedEventIds: {},
pinnedEventsSaveObject: {},
show: false,
status: 'draft',
updated: 1647542283361,
documentType: '',
isLoading: false,
queryFields: [],
selectAll: false,
};
export const mockState = pipe(
(state) => set(state, 'inputs.global.filters', filters),
(state) => set(state, 'inputs.timeline', input),
(state) => set(state, 'inputs.global.query', query),
(state) => set(state, 'inputs.global.queries', globalQueries),
(state) => set(state, 'inputs.timeline.queries', timelineQueries),
(state) => set(state, 'timeline.timelineById.alerts-page', timeline)
)(mockGlobalState);

View file

@ -13,8 +13,8 @@ import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import { TimelineTabs, TableId } from '@kbn/securitysolution-data-table';
import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../common/constants';
import {
selectNotesByDocumentId,
selectDocumentNotesBySavedObjectId,
makeSelectNotesByDocumentId,
makeSelectDocumentNotesBySavedObjectId,
} from '../../../notes/store/notes.slice';
import type { State } from '../../store';
import { selectTimelineById } from '../../../timelines/store/selectors';
@ -259,14 +259,15 @@ const ActionsComponent: React.FC<ActionProps> = ({
const securitySolutionNotesDisabled = useIsExperimentalFeatureEnabled(
'securitySolutionNotesDisabled'
);
const selectNotesByDocumentId = useMemo(() => makeSelectNotesByDocumentId(), []);
/* only applicable for new event based notes */
const documentBasedNotes = useSelector((state: State) => selectNotesByDocumentId(state, eventId));
const selectDocumentNotesBySavedObjectId = useMemo(
() => makeSelectDocumentNotesBySavedObjectId(),
[]
);
const documentBasedNotesInTimeline = useSelector((state: State) =>
selectDocumentNotesBySavedObjectId(state, {
documentId: eventId,
savedObjectId: savedObjectId ?? '',
})
selectDocumentNotesBySavedObjectId(state, eventId, savedObjectId ?? '')
);
/* note ids associated with the document AND attached to the current timeline, used for pinning */

View file

@ -8,11 +8,10 @@
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import React, { memo, useCallback, useContext, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { TableId } from '@kbn/securitysolution-data-table';
import { TableId, getTableByIdSelector } from '@kbn/securitysolution-data-table';
import { noop } from 'lodash';
import type { SetEventsLoading } from '../../../../common/types';
import { StatefulEventContext } from '../../../common/components/events_viewer/stateful_event_context';
import { eventsViewerSelector } from '../../../common/components/events_viewer/selectors';
import { useLicense } from '../../../common/hooks/use_license';
import type { TimelineItem } from '../../../../common/search_strategy';
import { getAlertsDefaultModel } from './default_config';
@ -20,6 +19,8 @@ import type { State } from '../../../common/store';
import { RowAction } from '../../../common/components/control_columns/row_action';
import type { GetSecurityAlertsTableProp } from './types';
const onRowSelected = () => {};
export const ActionsCellComponent: GetSecurityAlertsTableProp<'renderActionsCell'> = ({
tableType = TableId.alertsOnAlertsPage,
rowIndex,
@ -36,14 +37,14 @@ export const ActionsCellComponent: GetSecurityAlertsTableProp<'renderActionsCell
leadingControlColumn,
}) => {
const license = useLicense();
const defaults = useMemo(() => getAlertsDefaultModel(license), [license]);
const selectTableById = useMemo(() => getTableByIdSelector(), []);
const {
dataTable: {
columns: columnHeaders,
showCheckboxes,
selectedEventIds,
loadingEventIds,
} = getAlertsDefaultModel(license),
} = useSelector((state: State) => eventsViewerSelector(state, tableType));
columns: columnHeaders,
showCheckboxes,
selectedEventIds,
loadingEventIds,
} = useSelector((state: State) => selectTableById(state, tableType) ?? defaults);
const eventContext = useContext(StatefulEventContext);
const timelineItem = useMemo<TimelineItem>(
@ -80,7 +81,7 @@ export const ActionsCellComponent: GetSecurityAlertsTableProp<'renderActionsCell
isEventViewer={false}
isExpandable={isExpandable}
loadingEventIds={loadingEventIds}
onRowSelected={() => {}}
onRowSelected={onRowSelected}
rowIndex={rowIndex}
colIndex={colIndex}
pageRowIndex={rowIndex}

View file

@ -13,14 +13,9 @@ import type { AlertsTableProps, RenderContext } from '@kbn/response-ops-alerts-t
import { ALERT_BUILDING_BLOCK_TYPE, AlertConsumers } from '@kbn/rule-data-utils';
import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules';
import styled from 'styled-components';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { getEsQueryConfig } from '@kbn/data-plugin/public';
import {
dataTableActions,
dataTableSelectors,
tableDefaults,
TableId,
} from '@kbn/securitysolution-data-table';
import { dataTableActions, dataTableSelectors, TableId } from '@kbn/securitysolution-data-table';
import type { SetOptional } from 'type-fest';
import { noop } from 'lodash';
import type { Alert } from '@kbn/alerting-types';
@ -59,10 +54,9 @@ import { useSourcererDataView } from '../../../sourcerer/containers';
import type { RunTimeMappings } from '../../../sourcerer/store/model';
import { SourcererScopeName } from '../../../sourcerer/store/model';
import { useKibana, useUiSetting$ } from '../../../common/lib/kibana';
import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { getColumns, CellValue } from '../../configurations/security_solution_detections';
import { buildTimeRangeFilter } from './helpers';
import { eventsViewerSelector } from '../../../common/components/events_viewer/selectors';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import * as i18n from './translations';
import { eventRenderedViewColumns } from '../../configurations/security_solution_detections/columns';
@ -193,12 +187,17 @@ const DetectionEngineAlertsTableComponent: FC<Omit<DetectionEngineAlertTableProp
const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []);
const globalQuery = useDeepEqualSelector(getGlobalQuerySelector);
const globalFilters = useDeepEqualSelector(getGlobalFiltersQuerySelector);
const licenseDefaults = useMemo(() => getAlertsDefaultModel(license), [license]);
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
const isDataTableInitialized = useShallowEqualSelector(
(state) => (getTable(state, tableType) ?? tableDefaults).initialized
);
const {
initialized: isDataTableInitialized,
graphEventId,
sessionViewConfig,
viewMode: tableView = eventsDefaultModel.viewMode,
columns,
totalCount: count,
} = useSelector((state: State) => getTable(state, tableType) ?? licenseDefaults);
const timeRangeFilter = useMemo(() => buildTimeRangeFilter(from, to), [from, to]);
@ -206,16 +205,6 @@ const DetectionEngineAlertsTableComponent: FC<Omit<DetectionEngineAlertTableProp
return [...inputFilters, ...(globalFilters ?? []), ...(timeRangeFilter ?? [])];
}, [inputFilters, globalFilters, timeRangeFilter]);
const {
dataTable: {
graphEventId, // If truthy, the graph viewer (Resolver) is showing
sessionViewConfig,
viewMode: tableView = eventsDefaultModel.viewMode,
columns,
totalCount: count,
} = getAlertsDefaultModel(license),
} = useShallowEqualSelector((state: State) => eventsViewerSelector(state, tableType));
const combinedQuery = useMemo(() => {
if (browserFields != null && sourcererDataView) {
return combineQueries({
@ -289,7 +278,6 @@ const DetectionEngineAlertsTableComponent: FC<Omit<DetectionEngineAlertTableProp
const onUpdate: GetSecurityAlertsTableProp<'onUpdate'> = useCallback(
(context) => {
onLoad(context.alerts);
setTableContext(context);
dispatch(
updateIsLoading({
@ -310,7 +298,7 @@ const DetectionEngineAlertsTableComponent: FC<Omit<DetectionEngineAlertTableProp
inspect: null,
});
},
[dispatch, onLoad, setQuery, tableType]
[dispatch, setQuery, tableType]
);
const userProfiles = useFetchUserProfilesFromAlerts({
alerts: tableContext?.alerts ?? [],
@ -475,6 +463,7 @@ const DetectionEngineAlertsTableComponent: FC<Omit<DetectionEngineAlertTableProp
columns={finalColumns}
browserFields={finalBrowserFields}
onUpdate={onUpdate}
onLoaded={onLoad}
additionalContext={additionalContext}
height={alertTableHeight}
initialPageSize={50}

View file

@ -11,7 +11,12 @@ import { useDispatch, useSelector } from 'react-redux';
import type { Filter } from '@kbn/es-query';
import { getEsQueryConfig } from '@kbn/data-plugin/public';
import type { BulkActionsConfig } from '@kbn/response-ops-alerts-table/types';
import { dataTableActions, TableId, tableDefaults } from '@kbn/securitysolution-data-table';
import {
dataTableActions,
TableId,
tableDefaults,
getTableByIdSelector,
} from '@kbn/securitysolution-data-table';
import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy';
import type { CustomBulkAction } from '../../../../../common/types';
import { combineQueries } from '../../../../common/lib/kuery';
@ -20,7 +25,6 @@ import { BULK_ADD_TO_TIMELINE_LIMIT } from '../../../../../common/constants';
import { useSourcererDataView } from '../../../../sourcerer/containers';
import type { TimelineArgs } from '../../../../timelines/containers';
import { useTimelineEventsHandler } from '../../../../timelines/containers';
import { eventsViewerSelector } from '../../../../common/components/events_viewer/selectors';
import type { State } from '../../../../common/store/types';
import { useUpdateTimeline } from '../../../../timelines/components/open_timeline/use_update_timeline';
import { useCreateTimeline } from '../../../../timelines/hooks/use_create_timeline';
@ -31,7 +35,7 @@ import { sendBulkEventsToTimelineAction } from '../actions';
import type { CreateTimelineProps } from '../types';
import type { SourcererScopeName } from '../../../../sourcerer/store/model';
import type { Direction } from '../../../../../common/search_strategy';
import { globalFiltersQuerySelector } from '../../../../common/store/inputs/selectors';
const { setEventsLoading, setSelected } = dataTableActions;
export interface UseAddBulkToTimelineActionProps {
@ -74,8 +78,12 @@ export const useAddBulkToTimelineAction = ({
const dispatch = useDispatch();
const { uiSettings } = useKibana().services;
const { filters, dataTable: { selectAll, totalCount, sort, selectedEventIds } = tableDefaults } =
useSelector((state: State) => eventsViewerSelector(state, tableId));
const selectGlobalFiltersQuerySelector = useMemo(() => globalFiltersQuerySelector(), []);
const filters = useSelector(selectGlobalFiltersQuerySelector);
const selectTableById = useMemo(() => getTableByIdSelector(), []);
const { selectAll, totalCount, sort, selectedEventIds } = useSelector(
(state: State) => selectTableById(state, tableId) ?? tableDefaults
);
const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]);

View file

@ -28,7 +28,7 @@ import {
ReqStatus,
selectFetchNotesByDocumentIdsError,
selectFetchNotesByDocumentIdsStatus,
selectSortedNotesByDocumentId,
makeSelectNotesByDocumentId,
} from '../../../../notes/store/notes.slice';
import { useDocumentDetailsContext } from '../../shared/context';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
@ -95,13 +95,8 @@ export const NotesDetails = memo(() => {
);
}
}, [dispatch, eventId, timelineSavedObjectId, timeline.pinnedEventIds]);
const notes: Note[] = useSelector((state: State) =>
selectSortedNotesByDocumentId(state, {
documentId: eventId,
sort: { field: 'created', direction: 'asc' },
})
);
const selectNotesByDocumentId = useMemo(() => makeSelectNotesByDocumentId(), []);
const notes: Note[] = useSelector((state: State) => selectNotesByDocumentId(state, eventId));
const fetchStatus = useSelector((state: State) => selectFetchNotesByDocumentIdsStatus(state));
const fetchError = useSelector((state: State) => selectFetchNotesByDocumentIdsError(state));

View file

@ -28,6 +28,7 @@ import {
userSelectedRow,
userSortedNotes,
selectAllNotes,
selectAllReversed,
selectNotesPagination,
selectNotesTableSort,
fetchNotes,
@ -118,6 +119,7 @@ const pageSizeOptions = [10, 25, 50, 100];
export const NoteManagementPage = () => {
const dispatch = useDispatch();
const notes = useSelector(selectAllNotes);
const notesReversed = useSelector(selectAllReversed);
const pagination = useSelector(selectNotesPagination);
const sort = useSelector(selectNotesTableSort);
const notesSearch = useSelector(selectNotesTableSearch);
@ -129,7 +131,9 @@ export const NoteManagementPage = () => {
const fetchLoading = fetchNotesStatus === ReqStatus.Loading;
const fetchError = fetchNotesStatus === ReqStatus.Failed;
const fetchErrorData = useSelector(selectFetchNotesError);
const tableNotes = useMemo(() => {
return sort.direction === 'asc' ? notes : notesReversed;
}, [notes, notesReversed, sort.direction]);
const fetchData = useCallback(() => {
dispatch(
fetchNotes({
@ -230,7 +234,7 @@ export const NoteManagementPage = () => {
<EuiSpacer size="m" />
<NotesUtilityBar />
<EuiBasicTable
items={notes}
items={tableNotes}
pagination={currentPagination}
columns={columns}
onChange={onTableChange}

View file

@ -30,14 +30,14 @@ import {
selectNoteById,
selectNoteIds,
selectNotesByDocumentId,
selectNotesByDocumentIdReversed,
selectNotesBySavedObjectId,
selectNotesPagination,
selectNotesTablePendingDeleteIds,
selectNotesTableSearch,
selectNotesTableSelectedIds,
selectNotesTableSort,
selectSortedNotesByDocumentId,
selectSortedNotesBySavedObjectId,
selectNotesBySavedObjectIdReversed,
selectNotesTableCreatedByFilter,
selectNotesTableAssociatedFilter,
userClosedDeleteModal,
@ -174,7 +174,7 @@ describe('notesSlice', () => {
[newMockNote.noteId]: newMockNote,
[mockNote2.noteId]: mockNote2,
},
ids: [newMockNote.noteId, mockNote2.noteId],
ids: [mockNote2.noteId, newMockNote.noteId],
status: {
...initalEmptyState.status,
fetchNotesByDocumentIds: ReqStatus.Succeeded,
@ -256,7 +256,7 @@ describe('notesSlice', () => {
[newMockNote.noteId]: newMockNote,
[mockNote2.noteId]: mockNote2,
},
ids: [newMockNote.noteId, mockNote2.noteId],
ids: [mockNote2.noteId, newMockNote.noteId],
status: {
...initalEmptyState.status,
fetchNotesBySavedObjectIds: ReqStatus.Succeeded,
@ -712,31 +712,17 @@ describe('notesSlice', () => {
},
};
const ascResult = selectSortedNotesByDocumentId(state, {
documentId: '1',
sort: { field: 'created', direction: 'asc' },
});
const ascResult = selectNotesByDocumentId(state, '1');
expect(ascResult[0]).toEqual(oldestNote);
expect(ascResult[1]).toEqual(newestNote);
const descResult = selectSortedNotesByDocumentId(state, {
documentId: '1',
sort: { field: 'created', direction: 'desc' },
});
const descResult = selectNotesByDocumentIdReversed(state, '1');
expect(descResult[0]).toEqual(newestNote);
expect(descResult[1]).toEqual(oldestNote);
});
it('should also return no notes if document id does not exist', () => {
expect(
selectSortedNotesByDocumentId(mockGlobalState, {
documentId: 'wrong-document-id',
sort: {
field: 'created',
direction: 'desc',
},
})
).toHaveLength(0);
expect(selectNotesByDocumentId(mockGlobalState, 'wrong-document-id')).toHaveLength(0);
});
it('should return all notes for an existing saved object id', () => {
@ -783,43 +769,23 @@ describe('notesSlice', () => {
},
};
const ascResult = selectSortedNotesBySavedObjectId(state, {
savedObjectId: 'timeline-1',
sort: { field: 'created', direction: 'asc' },
});
const ascResult = selectNotesBySavedObjectId(state, 'timeline-1');
expect(ascResult[0]).toEqual(oldestNote);
expect(ascResult[1]).toEqual(newestNote);
const descResult = selectSortedNotesBySavedObjectId(state, {
savedObjectId: 'timeline-1',
sort: { field: 'created', direction: 'desc' },
});
const descResult = selectNotesBySavedObjectIdReversed(state, 'timeline-1');
expect(descResult[0]).toEqual(newestNote);
expect(descResult[1]).toEqual(oldestNote);
});
it('should also return no notes if saved object id does not exist', () => {
expect(
selectSortedNotesBySavedObjectId(mockGlobalState, {
savedObjectId: 'wrong-document-id',
sort: {
field: 'created',
direction: 'desc',
},
})
).toHaveLength(0);
expect(selectNotesBySavedObjectIdReversed(mockGlobalState, 'wrong-document-id')).toHaveLength(
0
);
});
it('should also return no notes if saved object id is empty string', () => {
expect(
selectSortedNotesBySavedObjectId(mockGlobalState, {
savedObjectId: '',
sort: {
field: 'created',
direction: 'desc',
},
})
).toHaveLength(0);
expect(selectNotesBySavedObjectIdReversed(mockGlobalState, '')).toHaveLength(0);
});
it('should select notes pagination', () => {

View file

@ -67,6 +67,11 @@ export interface NotesState extends EntityState<Note> {
const notesAdapter = createEntityAdapter<Note>({
selectId: (note: Note) => note.noteId,
sortComparer: (a: Note, b: Note) => {
const aCreated = a.created ?? 0;
const bCreated = b.created ?? 0;
return aCreated > bCreated ? 1 : -1;
},
});
export const initialNotesState: NotesState = notesAdapter.getInitialState({
@ -298,6 +303,12 @@ const notesSlice = createSlice({
export const notesReducer = notesSlice.reducer;
const EMPTY_ARRAY: [] = [];
export const fallbackToEmptyArray = <T>(array: T[]) => {
return array.length === 0 ? EMPTY_ARRAY : array;
};
export const {
selectAll: selectAllNotes,
selectById: selectNoteById,
@ -316,6 +327,15 @@ export const selectFetchNotesBySavedObjectIdsStatus = (state: State) =>
export const selectFetchNotesBySavedObjectIdsError = (state: State) =>
state.notes.error.fetchNotesBySavedObjectIds;
const selectIds = (state: State) => state.notes.ids;
const selectEntities = (state: State) => state.notes.entities;
export const selectAllReversed = createSelector(selectIds, selectEntities, (ids, entities) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
[...ids].reverse().map((id) => entities[id]!)
);
export const selectCreateNoteStatus = (state: State) => state.notes.status.createNote;
export const selectCreateNoteError = (state: State) => state.notes.error.createNote;
@ -344,15 +364,39 @@ export const selectFetchNotesStatus = (state: State) => state.notes.status.fetch
export const selectNotesByDocumentId = createSelector(
[selectAllNotes, (_: State, documentId: string) => documentId],
(notes, documentId) => notes.filter((note) => note.eventId === documentId)
(notes, documentId) => fallbackToEmptyArray(notes.filter((note) => note.eventId === documentId))
);
export const selectNotesByDocumentIdReversed = createSelector(
[selectAllReversed, (_: State, documentId: string) => documentId],
(notes, documentId) => fallbackToEmptyArray(notes.filter((note) => note.eventId === documentId))
);
export const makeSelectNotesByDocumentId = () =>
createSelector(
[selectAllNotes, (_: State, documentId: string) => documentId],
(notes, documentId) => fallbackToEmptyArray(notes.filter((note) => note.eventId === documentId))
);
export const selectNotesBySavedObjectId = createSelector(
[selectAllNotes, (_: State, savedObjectId: string) => savedObjectId],
(notes, savedObjectId) =>
savedObjectId.length > 0 ? notes.filter((note) => note.timelineId === savedObjectId) : []
fallbackToEmptyArray(notes.filter((note) => note.timelineId === savedObjectId))
);
export const selectNotesBySavedObjectIdReversed = createSelector(
[selectAllReversed, (_: State, savedObjectId: string) => savedObjectId],
(notes, savedObjectId) =>
fallbackToEmptyArray(notes.filter((note) => note.timelineId === savedObjectId))
);
export const makeSelectNotesBySavedObjectId = () =>
createSelector(
[selectAllNotes, (_: State, savedObjectId: string) => savedObjectId],
(notes, savedObjectId) =>
fallbackToEmptyArray(notes.filter((note) => note.timelineId === savedObjectId))
);
export const selectDocumentNotesBySavedObjectId = createSelector(
[
selectAllNotes,
@ -362,61 +406,23 @@ export const selectDocumentNotesBySavedObjectId = createSelector(
}),
],
(notes, { documentId, savedObjectId }) =>
notes.filter((note) => note.eventId === documentId && note.timelineId === savedObjectId)
fallbackToEmptyArray(
notes.filter((note) => note.eventId === documentId && note.timelineId === savedObjectId)
)
);
export const selectSortedNotesByDocumentId = createSelector(
[
selectAllNotes,
(
_: State,
{
documentId,
sort,
}: { documentId: string; sort: { field: keyof Note; direction: 'asc' | 'desc' } }
) => ({ documentId, sort }),
],
(notes, { documentId, sort }) => {
const { field, direction } = sort;
return notes
.filter((note: Note) => note.eventId === documentId)
.sort((first: Note, second: Note) => {
const a = first[field];
const b = second[field];
if (a == null) return 1;
if (b == null) return -1;
return direction === 'asc' ? (a > b ? 1 : -1) : a > b ? -1 : 1;
});
}
);
export const selectSortedNotesBySavedObjectId = createSelector(
[
selectAllNotes,
(
_: State,
{
savedObjectId,
sort,
}: { savedObjectId: string; sort: { field: keyof Note; direction: 'asc' | 'desc' } }
) => ({ savedObjectId, sort }),
],
(notes, { savedObjectId, sort }) => {
const { field, direction } = sort;
if (savedObjectId.length === 0) {
return [];
}
return notes
.filter((note: Note) => note.timelineId === savedObjectId)
.sort((first: Note, second: Note) => {
const a = first[field];
const b = second[field];
if (a == null) return 1;
if (b == null) return -1;
return direction === 'asc' ? (a > b ? 1 : -1) : a > b ? -1 : 1;
});
}
);
export const makeSelectDocumentNotesBySavedObjectId = () =>
createSelector(
[
selectAllNotes,
(_: State, documentId: string) => documentId,
(_: State, documentId: string, savedObjectId: string) => savedObjectId,
],
(notes, documentId, savedObjectId) =>
fallbackToEmptyArray(
notes.filter((note) => note.eventId === documentId && note.timelineId === savedObjectId)
)
);
export const {
userSelectedPage,

View file

@ -130,8 +130,9 @@ export const EqlTabContentComponent: React.FC<Props> = ({
itemsPerPage * pageIndex,
itemsPerPage * (pageIndex + 1)
);
loadNotesOnEventsLoad(eventsOnCurrentPage);
if (eventsOnCurrentPage.length > 0) {
loadNotesOnEventsLoad(eventsOnCurrentPage);
}
}, [events, pageIndex, itemsPerPage, loadNotesOnEventsLoad]);
/**

View file

@ -42,7 +42,7 @@ import * as i18n from './translations';
import { useLicense } from '../../../../common/hooks/use_license';
import { initializeTimelineSettings } from '../../../store/actions';
import { selectTimelineById, selectTimelineESQLSavedSearchId } from '../../../store/selectors';
import { fetchNotesBySavedObjectIds, selectSortedNotesBySavedObjectId } from '../../../../notes';
import { fetchNotesBySavedObjectIds, makeSelectNotesBySavedObjectId } from '../../../../notes';
import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
@ -314,11 +314,10 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
}
}, [fetchNotes, isTimelineSaved]);
const selectNotesBySavedObjectId = useMemo(() => makeSelectNotesBySavedObjectId(), []);
const notesNewSystem = useSelector((state: State) =>
selectSortedNotesBySavedObjectId(state, {
savedObjectId: timelineSavedObjectId,
sort: { field: 'created', direction: 'asc' },
})
selectNotesBySavedObjectId(state, timelineSavedObjectId)
);
const numberOfNotesNewSystem = useMemo(
() => notesNewSystem.length + (isEmpty(timelineDescription) ? 0 : 1),

View file

@ -38,7 +38,7 @@ import {
ReqStatus,
selectFetchNotesBySavedObjectIdsError,
selectFetchNotesBySavedObjectIdsStatus,
selectSortedNotesBySavedObjectId,
makeSelectNotesBySavedObjectId,
} from '../../../../../notes';
import type { Note } from '../../../../../../common/api/timeline';
import { TimelineStatusEnum } from '../../../../../../common/api/timeline';
@ -116,12 +116,10 @@ const NotesTabContentComponent: React.FC<NotesTabContentProps> = React.memo(({ t
fetchNotes();
}
}, [fetchNotes, isTimelineSaved]);
const selectNotesBySavedObjectId = useMemo(() => makeSelectNotesBySavedObjectId(), []);
const notes: Note[] = useSelector((state: State) =>
selectSortedNotesBySavedObjectId(state, {
savedObjectId: timelineSavedObjectId,
sort: { field: 'created', direction: 'asc' },
})
selectNotesBySavedObjectId(state, timelineSavedObjectId)
);
const fetchStatus = useSelector((state: State) => selectFetchNotesBySavedObjectIdsStatus(state));
const fetchError = useSelector((state: State) => selectFetchNotesBySavedObjectIdsError(state));

View file

@ -164,8 +164,9 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
itemsPerPage * pageIndex,
itemsPerPage * (pageIndex + 1)
);
loadNotesOnEventsLoad(eventsOnCurrentPage);
if (eventsOnCurrentPage.length > 0) {
loadNotesOnEventsLoad(eventsOnCurrentPage);
}
}, [events, pageIndex, itemsPerPage, loadNotesOnEventsLoad]);
/**

View file

@ -198,8 +198,9 @@ export const QueryTabContentComponent: React.FC<Props> = ({
itemsPerPage * pageIndex,
itemsPerPage * (pageIndex + 1)
);
loadNotesOnEventsLoad(eventsOnCurrentPage);
if (eventsOnCurrentPage.length > 0) {
loadNotesOnEventsLoad(eventsOnCurrentPage);
}
}, [events, pageIndex, itemsPerPage, loadNotesOnEventsLoad]);
/**