diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/table.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/table.tsx index 5f97d2d2d6f86..72fc8e3ea3555 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/ai_for_soc/table.tsx @@ -11,6 +11,8 @@ import { AlertsTable } from '@kbn/response-ops-alerts-table'; import type { PackageListItem } from '@kbn/fleet-plugin/common'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { AlertsTableImperativeApi } from '@kbn/response-ops-alerts-table/types'; +import { useBrowserFields } from '../../../../../../../data_view_manager/hooks/use_browser_fields'; +import { DataViewManagerScopeName } from '../../../../../../../data_view_manager/constants'; import type { AdditionalTableContext } from '../../../../../../../detections/components/alert_summary/table/table'; import { ACTION_COLUMN_WIDTH, @@ -24,7 +26,6 @@ import { TOOLBAR_VISIBILITY, } from '../../../../../../../detections/components/alert_summary/table/table'; import { ActionsCell } from '../../../../../../../detections/components/alert_summary/table/actions_cell'; -import { getDataViewStateFromIndexFields } from '../../../../../../../common/containers/source/use_data_view'; import { useKibana } from '../../../../../../../common/lib/kibana'; import { CellValue } from '../../../../../../../detections/components/alert_summary/table/render_cell'; import type { RuleResponse } from '../../../../../../../../common/api/detection_engine'; @@ -84,12 +85,7 @@ export const Table = memo(({ dataView, id, packages, query, ruleResponse }: Tabl [application, cases, data, fieldFormats, http, licensing, notifications, settings] ); - const dataViewSpec = useMemo(() => dataView.toSpec(), [dataView]); - - const { browserFields } = useMemo( - () => getDataViewStateFromIndexFields('', dataViewSpec.fields), - [dataViewSpec.fields] - ); + const browserFields = useBrowserFields(DataViewManagerScopeName.detections, dataView); const additionalContext: AdditionalTableContext = useMemo( () => ({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/table.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/table.tsx index 8e2cca6ef0f15..9d0acb7603f15 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/components/ai_for_soc/table.tsx @@ -15,10 +15,11 @@ import type { } from '@kbn/response-ops-alerts-table/types'; import React, { memo, useCallback, useMemo, useRef } from 'react'; import type { RuleResponse } from '../../../../common/api/detection_engine'; -import { getDataViewStateFromIndexFields } from '../../../common/containers/source/use_data_view'; import { useKibana } from '../../../common/lib/kibana'; import { ActionsCell } from '../../../detections/components/alert_summary/table/actions_cell'; import { CellValue } from '../../../detections/components/alert_summary/table/render_cell'; +import { useBrowserFields } from '../../../data_view_manager/hooks/use_browser_fields'; +import { DataViewManagerScopeName } from '../../../data_view_manager/constants'; import type { AdditionalTableContext } from '../../../detections/components/alert_summary/table/table'; import { ACTION_COLUMN_WIDTH, @@ -101,12 +102,7 @@ export const Table = memo( [application, cases, data, fieldFormats, http, licensing, notifications, settings] ); - const dataViewSpec = useMemo(() => dataView.toSpec(), [dataView]); - - const { browserFields } = useMemo( - () => getDataViewStateFromIndexFields('', dataViewSpec.fields), - [dataViewSpec.fields] - ); + const browserFields = useBrowserFields(DataViewManagerScopeName.detections, dataView); const additionalContext: AdditionalTableContext = useMemo( () => ({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/containers/source/index.tsx index 384f1e88a0fbe..5d7b03abe66e2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/containers/source/index.tsx @@ -13,9 +13,9 @@ import type { BrowserFields } from '@kbn/timelines-plugin/common'; import type { FieldSpec, IIndexPatternFieldList } from '@kbn/data-views-plugin/common'; import type { DataViewSpec } from '@kbn/data-views-plugin/public'; +import { browserFieldsManager } from '../../../data_view_manager/utils/security_browser_fields_manager'; import { useKibana } from '../../lib/kibana'; import * as i18n from './translations'; -import { getDataViewStateFromIndexFields } from './use_data_view'; import { useAppToasts } from '../../hooks/use_app_toasts'; import type { ENDPOINT_FIELDS_SEARCH_STRATEGY } from '../../../../common/endpoint/constants'; @@ -115,10 +115,7 @@ export const useFetchIndex = ( abortCtrl.current = new AbortController(); const dv = await data.dataViews.create({ title: iNames.join(','), allowNoIndex: true }); const dataView = dv.toSpec(); - const { browserFields } = getDataViewStateFromIndexFields( - iNames.join(','), - dataView.fields - ); + const { browserFields } = browserFieldsManager.getBrowserFields(dv); previousIndexesName.current = dv.getIndexPattern().split(','); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/containers/source/use_data_view.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/containers/source/use_data_view.tsx index 1dd1bf2877438..fcab84e3a225b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/containers/source/use_data_view.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/containers/source/use_data_view.tsx @@ -9,6 +9,7 @@ import { useCallback, useRef } from 'react'; import type { Subscription } from 'rxjs'; import { useDispatch } from 'react-redux'; import memoizeOne from 'memoize-one'; +import deepEqual from 'fast-deep-equal'; import type { BrowserFields } from '@kbn/timelines-plugin/common'; import type { DataViewSpec } from '@kbn/data-views-plugin/public'; import type { FieldCategory } from '@kbn/timelines-plugin/common/search_strategy'; @@ -44,6 +45,9 @@ interface DataViewInfo { /** * HOT Code path where the fields can be 16087 in length or larger. This is * VERY mutatious on purpose to improve the performance of the transform. + * TODO: newDataViewPickerEnabled - consider removing this in favor of the + * buildBrowserFieldsFromDataView util at x-pack/solutions/security/plugins/security_solution/public/data_view_manager/utils/build_browser_fields.ts + * which utilizes the less expensive DataView.fields instead of the DataViewSpec.fields which is much more expensive to build. */ export const getDataViewStateFromIndexFields = memoizeOne( (_title: string, fields: DataViewSpec['fields']): DataViewInfo => { @@ -66,7 +70,7 @@ export const getDataViewStateFromIndexFields = memoizeOne( return { browserFields: browserFields as DangerCastForBrowserFieldsMutation }; } }, - (newArgs, lastArgs) => newArgs[0] === lastArgs[0] && newArgs[1]?.length === lastArgs[1]?.length + (newArgs, lastArgs) => deepEqual(newArgs, lastArgs) // DataViewSpec['fields'] is an object, so we cannot do a length check. ); export const useDataView = (): { diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx index caa290c093ce1..e473c5ebfe173 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx @@ -19,6 +19,7 @@ import { TestProviders } from '../../../common/mock/test_providers'; import { useSelectDataView } from '../../hooks/use_select_data_view'; import { useUpdateUrlParam } from '../../../common/utils/global_query_string'; import { URL_PARAM_KEY } from '../../../common/hooks/constants'; +import { useKibana as mockUseKibana } from '../../../common/lib/kibana/__mocks__'; jest.mock('../../../common/utils/global_query_string', () => ({ useUpdateUrlParam: jest.fn(), @@ -39,9 +40,7 @@ jest.mock('react-redux', () => { }; }); -jest.mock('../../../common/lib/kibana', () => ({ - useKibana: jest.fn(), -})); +jest.mock('../../../common/lib/kibana'); jest.mock('@kbn/unified-search-plugin/public', () => ({ ...jest.requireActual('@kbn/unified-search-plugin/public'), @@ -93,12 +92,12 @@ describe('DataViewPicker', () => { jest.mocked(useKibana).mockReturnValue({ services: { + ...mockUseKibana().services, dataViewFieldEditor: { openEditor: jest.fn() }, dataViewEditor: { openEditor: jest.fn(), userPermissions: { editDataView: jest.fn().mockReturnValue(true) }, }, - data: { dataViews: { get: jest.fn() } }, }, } as unknown as ReturnType); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx index da54dc8b05243..40513f0af7a46 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx @@ -23,6 +23,7 @@ import { useSavedDataViews } from '../../hooks/use_saved_data_views'; import { DEFAULT_SECURITY_DATA_VIEW, LOADING } from './translations'; import { DATA_VIEW_PICKER_TEST_ID } from './constants'; import { useDataView } from '../../hooks/use_data_view'; +import { browserFieldsManager } from '../../utils/security_browser_fields_manager'; interface DataViewPickerProps { /** @@ -75,6 +76,7 @@ export const DataViewPicker = memo(({ scope, onClosePopover, disabled }: DataVie // hence - it is the only place where we should update the url param for the data view selection. const handleChangeDataView = useCallback( (id: string, indexPattern: string = '') => { + browserFieldsManager.removeFromCache(scope); selectDataView({ id, scope }); if (isDefaultSourcerer) { @@ -111,6 +113,9 @@ export const DataViewPicker = memo(({ scope, onClosePopover, disabled }: DataVie } const dataViewInstance = await data.dataViews.get(dataViewId); + // Modifications to the fields do not trigger cache invalidation, but should as `fields` will be stale. + data.dataViews.clearInstanceCache(dataViewId); + browserFieldsManager.removeFromCache(scope); closeFieldEditor.current = await dataViewFieldEditor.openEditor({ ctx: { @@ -126,7 +131,7 @@ export const DataViewPicker = memo(({ scope, onClosePopover, disabled }: DataVie }, }); }, - [dataViewId, data.dataViews, dataViewFieldEditor, handleChangeDataView] + [dataViewId, data.dataViews, scope, dataViewFieldEditor, handleChangeDataView] ); /** diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.test.ts index d5fa20c2e058a..f0426d5e0fb7f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.test.ts @@ -9,30 +9,43 @@ import { renderHook } from '@testing-library/react'; import { TestProviders } from '../../common/mock'; import { useBrowserFields } from './use_browser_fields'; import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, DataViewManagerScopeName } from '../constants'; -import { useDataViewSpec } from './use_data_view_spec'; -import { type FieldSpec } from '@kbn/data-views-plugin/common'; +import { useDataView } from './use_data_view'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; -jest.mock('./use_data_view_spec', () => ({ - useDataViewSpec: jest.fn(), +jest.mock('../../common/hooks/use_experimental_features'); + +jest.mock('./use_data_view', () => ({ + useDataView: jest.fn(), })); describe('useBrowserFields', () => { beforeAll(() => { - jest.mocked(useDataViewSpec).mockReturnValue({ - dataViewSpec: { - id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, - fields: { - '@timestamp': { - type: 'date', - name: '@timestamp', - } as FieldSpec, + jest.mocked(useDataView).mockReturnValue({ + dataView: new DataView({ + spec: { + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + title: 'security-solution-data-view', + fields: { + '@timestamp': { + name: '@timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + scripted: false, + }, + }, }, - }, + // @ts-expect-error: DataView constructor expects more, but this is enough for our test + fieldFormats: { getDefaultInstance: () => ({}) }, + }), status: 'ready', }); }); it('should call the useDataView hook and return browser fields map', () => { + jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(true); const wrapper = renderHook(() => useBrowserFields(DataViewManagerScopeName.default), { wrapper: TestProviders, }); @@ -42,7 +55,62 @@ describe('useBrowserFields', () => { "base": Object { "fields": Object { "@timestamp": Object { + "aggregatable": true, + "esTypes": Array [ + "date", + ], + "name": "@timestamp", + "scripted": false, + "searchable": true, + "shortDotsEnable": false, + "type": "date", + }, + }, + }, + } + `); + }); + + it('should use the passed in dataView when the feature flag is disabled', () => { + jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(false); + const oldDataView = new DataView({ + spec: { + id: 'old-dataView', + title: 'security-solution-data-view-old', + fields: { + '@timestamp': { + name: '@timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + scripted: false, + }, + }, + }, + // @ts-expect-error: DataView constructor expects more, but this is enough for our test + fieldFormats: { getDefaultInstance: () => ({}) }, + }); + const wrapper = renderHook( + () => useBrowserFields(DataViewManagerScopeName.default, oldDataView), + { + wrapper: TestProviders, + } + ); + + expect(wrapper.result.current).toMatchInlineSnapshot(` + Object { + "base": Object { + "fields": Object { + "@timestamp": Object { + "aggregatable": true, + "esTypes": Array [ + "date", + ], "name": "@timestamp", + "scripted": false, + "searchable": true, + "shortDotsEnable": false, "type": "date", }, }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.ts index f498e841a5791..d818931a1addc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.ts @@ -7,25 +7,30 @@ import { useMemo } from 'react'; import type { BrowserFields } from '@kbn/timelines-plugin/common'; +import type { DataView } from '@kbn/data-views-plugin/common'; import { DataViewManagerScopeName } from '../constants'; -import { useDataViewSpec } from './use_data_view_spec'; -import { getDataViewStateFromIndexFields } from '../../common/containers/source/use_data_view'; +import { useDataView } from './use_data_view'; +import { browserFieldsManager } from '../utils/security_browser_fields_manager'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; export const useBrowserFields = ( - scope: DataViewManagerScopeName = DataViewManagerScopeName.default + scope: DataViewManagerScopeName = DataViewManagerScopeName.default, + /** + * @deprecated remove when newDataViewPickerEnabled is removed + */ + oldDataView?: DataView ): BrowserFields => { - const { dataViewSpec } = useDataViewSpec(scope); + const { dataView } = useDataView(scope); + const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); + const activeDataView = newDataViewPickerEnabled ? dataView : oldDataView; return useMemo(() => { - if (!dataViewSpec) { + if (!activeDataView) { return {}; } - const { browserFields } = getDataViewStateFromIndexFields( - dataViewSpec?.title ?? '', - dataViewSpec.fields - ); + const { browserFields } = browserFieldsManager.getBrowserFields(activeDataView, scope); return browserFields; - }, [dataViewSpec]); + }, [activeDataView, scope]); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/utils/security_browser_fields_manager.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/utils/security_browser_fields_manager.test.ts new file mode 100644 index 0000000000000..7740ca47ac318 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/utils/security_browser_fields_manager.test.ts @@ -0,0 +1,185 @@ +/* + * 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 { browserFieldsManager } from './security_browser_fields_manager'; +import { DataView } from '@kbn/data-views-plugin/public'; +import type { DataViewSpec, FieldSpec } from '@kbn/data-views-plugin/common'; +import { DataViewManagerScopeName } from '../constants'; + +const createDataView = (fields: Array>, title = 'test-title'): DataView => { + // DataView expects a spec with a fields object keyed by field name + const spec: DataViewSpec = { + id: 'test-id', + title, + fields: fields.reduce((acc, f) => { + if (f.name !== undefined) { + acc[f.name] = { + name: f.name, + type: f.type ?? 'string', + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + scripted: false, + }; + } + return acc; + }, {} as Record), + }; + // @ts-expect-error: DataView constructor expects more, but this is enough for our test + return new DataView({ spec, fieldFormats: { getDefaultInstance: () => ({}) } }); +}; + +describe('browserFieldsManager', () => { + it('returns empty browserFields for empty array', () => { + const dataView = createDataView([]); + const result = browserFieldsManager.getBrowserFields(dataView); + expect(result.browserFields).toEqual({}); + }); + + it('groups fields by category', () => { + const dataView = createDataView([ + { name: 'host.name' }, + { name: 'host.ip' }, + { name: 'user.name' }, + { name: 'event.category' }, + { name: 'event.action' }, + { name: 'basefield' }, + ]); + const result = browserFieldsManager.getBrowserFields(dataView); + expect(result.browserFields).toHaveProperty('host'); + expect(result.browserFields).toHaveProperty('user'); + expect(result.browserFields).toHaveProperty('event'); + expect(result.browserFields).toHaveProperty('base'); + expect(result.browserFields.host.fields).toHaveProperty(['host.name']); + expect(result.browserFields.host.fields).toHaveProperty(['host.ip']); + expect(result.browserFields.user.fields).toHaveProperty(['user.name']); + expect(result.browserFields.event.fields).toHaveProperty(['event.category']); + expect(result.browserFields.event.fields).toHaveProperty(['event.action']); + expect(result.browserFields.base.fields).toHaveProperty(['basefield']); + }); + + it('handles fields with missing type gracefully', () => { + const dataView = createDataView([{ name: 'host.name' }]); + // Remove type from the DataViewField + // @ts-expect-error + dataView.getFieldByName('host.name').spec.type = undefined; + const result = browserFieldsManager.getBrowserFields(dataView); + expect(result.browserFields.host.fields).toHaveProperty(['host.name']); + expect(result.browserFields.host.fields['host.name'].type).toBeUndefined(); + }); + + describe('memoization', () => { + it('should not memoize when different fields are provided with the same title', () => { + const dataView1 = createDataView([{ name: 'host.name' }]); + const dataView2 = createDataView([{ name: 'user.name' }]); + const result1 = browserFieldsManager.getBrowserFields(dataView1); + const result2 = browserFieldsManager.getBrowserFields(dataView2); + expect(result1).not.toBe(result2); + }); + + it('should memoize browserFields for the same dataView title', () => { + const dataView = createDataView([{ name: 'host.name' }]); + const result1 = browserFieldsManager.getBrowserFields( + dataView, + DataViewManagerScopeName.detections + ); + const result2 = browserFieldsManager.getBrowserFields( + dataView, + DataViewManagerScopeName.detections + ); + expect(result1).toBe(result2); + }); + + it('should return the same browserFields for different scopes if the dataView is the same', () => { + const dataView = createDataView([{ name: 'host.name' }]); + const result1 = browserFieldsManager.getBrowserFields( + dataView, + DataViewManagerScopeName.detections + ); + const result2 = browserFieldsManager.getBrowserFields( + dataView, + DataViewManagerScopeName.default + ); + expect(result1).toBe(result2); + expect(result1.browserFields).toEqual(result2.browserFields); + }); + + it('should return different browserFields for different scopes with different dataViews', () => { + const dataView1 = createDataView([{ name: 'host.name' }]); + const dataView2 = createDataView([{ name: 'user.name' }], 'other-title'); + const result1 = browserFieldsManager.getBrowserFields( + dataView1, + DataViewManagerScopeName.detections + ); + const result2 = browserFieldsManager.getBrowserFields( + dataView2, + DataViewManagerScopeName.default + ); + expect(result1).not.toBe(result2); + expect(result1.browserFields).not.toEqual(result2.browserFields); + expect(result1.browserFields.host).toBeDefined(); + expect(result2.browserFields.user).toBeDefined(); + }); + + it('should clear cache correctly', () => { + const dataView = createDataView([{ name: 'host.name' }]); + const result1 = browserFieldsManager.getBrowserFields( + dataView, + DataViewManagerScopeName.detections + ); + browserFieldsManager.clearCache(); + const result2 = browserFieldsManager.getBrowserFields( + dataView, + DataViewManagerScopeName.detections + ); + expect(result1).not.toBe(result2); + }); + + it('should return cached value if it still exists in cache for another scope', () => { + const dataView = createDataView([{ name: 'host.name' }]); + const result1 = browserFieldsManager.getBrowserFields( + dataView, + DataViewManagerScopeName.detections + ); + const result2 = browserFieldsManager.getBrowserFields( + dataView, + DataViewManagerScopeName.default + ); + browserFieldsManager.removeFromCache(DataViewManagerScopeName.detections); + const result3 = browserFieldsManager.getBrowserFields( + dataView, + DataViewManagerScopeName.detections + ); + expect(result1).toBe(result3); + expect(result2).toBe(result3); + }); + + it('should clear the entire cache when clearCache is called', () => { + const dataView1 = createDataView([{ name: 'host.name' }]); + const dataView2 = createDataView([{ name: 'user.name' }], 'other-title'); + const result1 = browserFieldsManager.getBrowserFields( + dataView1, + DataViewManagerScopeName.detections + ); + const result2 = browserFieldsManager.getBrowserFields( + dataView2, + DataViewManagerScopeName.default + ); + browserFieldsManager.clearCache(); + const result3 = browserFieldsManager.getBrowserFields( + dataView1, + DataViewManagerScopeName.detections + ); + const result4 = browserFieldsManager.getBrowserFields( + dataView2, + DataViewManagerScopeName.default + ); + expect(result1).not.toBe(result3); + expect(result2).not.toBe(result4); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/utils/security_browser_fields_manager.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/utils/security_browser_fields_manager.ts new file mode 100644 index 0000000000000..3d0c62e9e47f1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/utils/security_browser_fields_manager.ts @@ -0,0 +1,149 @@ +/* + * 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 type { BrowserFields } from '@kbn/timelines-plugin/common'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { getCategory } from '@kbn/response-ops-alerts-fields-browser/helpers'; +import type { DataViewManagerScopeName } from '../constants'; + +type DataViewTitle = ReturnType; +interface BrowserFieldsResult { + browserFields: BrowserFields; +} + +/** + * SecurityBrowserFieldsManager is a singleton class that manages the browser fields + * for the Security Solution. It caches the browser fields to improve performance + * when accessing the fields multiple times across multiple scopes. + */ +class SecurityBrowserFieldsManager { + private static instance: SecurityBrowserFieldsManager; + private scopeToDataViewIndexPatternsCache = new Map(); + private dataViewIndexPatternsToBrowserFieldsCache = new Map(); + + constructor() { + if (SecurityBrowserFieldsManager.instance) { + return SecurityBrowserFieldsManager.instance; + } + SecurityBrowserFieldsManager.instance = this; + } + + /** + * Builds the browser fields from the provided dataView fields. + * @param fields - The fields from the dataView to be processed. + * @returns An object containing the browserFields. + */ + private buildBrowserFields(fields: DataView['fields']): BrowserFieldsResult { + if (fields == null) return { browserFields: {} }; + + const browserFields: BrowserFields = {}; + for (let i = 0; i < fields.length; i++) { + const field = fields[i].spec; + const name = field.name; + if (name != null) { + const category = getCategory(name); + if (browserFields[category] == null) { + browserFields[category] = { fields: {} }; + } + const categoryFields = browserFields[category].fields; + if (categoryFields) { + categoryFields[name] = field; + } + } + } + return { browserFields }; + } + + /** + * + * @param dataViewtitle - The title of the dataView, which is used as a key for caching. + * This is typically the index pattern of the dataView. + * @param scope - The scope of the data view manager, used to differentiate between different contexts. + * @returns The cached browser fields for the specified dataView title and scope, or undefined if not found. + */ + private getCachedBrowserFields( + dataViewTitle: DataViewTitle, + scope: DataViewManagerScopeName + ): BrowserFieldsResult | undefined { + // Check if the scope is already mapped to a dataView title + const cachedDataViewTitle = this.scopeToDataViewIndexPatternsCache.get(scope); + if (cachedDataViewTitle && cachedDataViewTitle === dataViewTitle) { + // If the title matches, return the cached browser fields + const cachedResult = this.dataViewIndexPatternsToBrowserFieldsCache.get(cachedDataViewTitle); + if (cachedResult) { + return cachedResult; + } + } + // If the title does not match or is not cached, update the cache with the new title + this.scopeToDataViewIndexPatternsCache.set(scope, dataViewTitle); + // Check if the browser fields for this title are already cached + const cachedBrowserFields = this.dataViewIndexPatternsToBrowserFieldsCache.get(dataViewTitle); + if (cachedBrowserFields) { + return cachedBrowserFields; + } + return undefined; + } + /** + * + * @param dataView - The dataView containing the fields to be processed. + * @param [scope] - Optional The scope of the data view manager, used to differentiate between different contexts. + * If passed, will use cache for the specified scope, but can be ignored if caching is not desired. + * @returns An object containing the browserFields built from the dataView fields. + */ + public getBrowserFields( + dataView: DataView, + scope?: DataViewManagerScopeName + ): BrowserFieldsResult { + const { fields } = dataView; + // If the dataView has no fields, return an empty browserFields object + if (!fields || fields.length === 0) { + return { browserFields: {} }; + } + + const indexPatterns = dataView.getIndexPattern(); + + // Caching depends on the scope and title + if (scope && indexPatterns) { + const cachedResult = this.getCachedBrowserFields(indexPatterns, scope); + if (cachedResult) { + // If the browser fields for this indexPatterns are cached, return them + return cachedResult; + } + // If the browser fields for this indexPatterns are not cached, build them + const result = this.buildBrowserFields(fields); + this.dataViewIndexPatternsToBrowserFieldsCache.set(indexPatterns, result); + return result; + } + + // If scope is not provided or title is not defined, return the browser fields without caching + return this.buildBrowserFields(fields); + } + + public removeFromCache(scope: DataViewManagerScopeName): void { + const indexPatterns = this.scopeToDataViewIndexPatternsCache.get(scope); + if (indexPatterns) { + this.scopeToDataViewIndexPatternsCache.delete(scope); + const scopesUsingIndexPattern = Array.from(this.scopeToDataViewIndexPatternsCache.values()); + + if (!scopesUsingIndexPattern.includes(indexPatterns)) { + // If no other scope is using this indexPattern, remove it from the browser fields cache + this.dataViewIndexPatternsToBrowserFieldsCache.delete(indexPatterns); + } + } + } + + /** + * Clear all caches in the SecurityBrowserFieldsManager. + * This method is useful for resetting the state of the manager, especially during tests + */ + public clearCache(): void { + this.scopeToDataViewIndexPatternsCache.clear(); + this.dataViewIndexPatternsToBrowserFieldsCache.clear(); + } +} + +export const browserFieldsManager = new SecurityBrowserFieldsManager(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx index 9371168ac76ac..41b25a9825fcd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx @@ -31,11 +31,12 @@ import type { } from '@elastic/eui'; import type { PackageListItem } from '@kbn/fleet-plugin/common'; import styled from '@emotion/styled'; +import { useBrowserFields } from '../../../../data_view_manager/hooks/use_browser_fields'; +import { DataViewManagerScopeName } from '../../../../data_view_manager/constants'; import { useAdditionalBulkActions } from '../../../hooks/alert_summary/use_additional_bulk_actions'; import { APP_ID, CASES_FEATURE_ID } from '../../../../../common'; import { ActionsCell } from './actions_cell'; import { AdditionalToolbarControls } from './additional_toolbar_controls'; -import { getDataViewStateFromIndexFields } from '../../../../common/containers/source/use_data_view'; import { inputsSelectors } from '../../../../common/store'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { combineQueries } from '../../../../common/lib/kuery'; @@ -205,10 +206,7 @@ export const Table = memo(({ dataView, groupingFilters, packages, ruleResponse } const dataViewSpec = useMemo(() => dataView.toSpec(), [dataView]); - const { browserFields } = useMemo( - () => getDataViewStateFromIndexFields('', dataViewSpec.fields), - [dataViewSpec.fields] - ); + const browserFields = useBrowserFields(DataViewManagerScopeName.detections, dataView); const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); const globalQuery = useDeepEqualSelector(getGlobalQuerySelector); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx index a8a48567c8501..e65b5fb7298fc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx @@ -94,6 +94,7 @@ describe('useFieldBrowserOptions', () => { mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => true; useKibanaMock().services.dataViewFieldEditor = mockIndexPatternFieldEditor; useKibanaMock().services.data.dataViews.get = () => new Promise(() => undefined); + useKibanaMock().services.data.dataViews.clearInstanceCache = () => undefined; useKibanaMock().services.application.capabilities = { ...useKibanaMock().services.application.capabilities, diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/fields_browser/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/fields_browser/index.tsx index c9acc48051911..3a0f8d38c68d0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/fields_browser/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/fields_browser/index.tsx @@ -13,8 +13,11 @@ import type { CreateFieldComponent, GetFieldTableColumns, } from '@kbn/response-ops-alerts-fields-browser/types'; +import { browserFieldsManager } from '../../../data_view_manager/utils/security_browser_fields_manager'; import type { ColumnHeaderOptions } from '../../../../common/types'; -import { useDataView } from '../../../common/containers/source/use_data_view'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { useDataView } from '../../../data_view_manager/hooks/use_data_view'; +import { useDataView as useDataViewOld } from '../../../common/containers/source/use_data_view'; import { useKibana } from '../../../common/lib/kibana'; import { sourcererSelectors } from '../../../common/store'; import type { State } from '../../../common/store'; @@ -44,16 +47,21 @@ export type UseFieldBrowserOptions = (props: UseFieldBrowserOptionsProps) => { getFieldTableColumns: GetFieldTableColumns; }; +/** + * This hook is used in the alerts table and explore page tables (StatefulEventsViewer) to manage field browser options. + */ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({ sourcererScope, editorActionsRef, removeColumn, upsertColumn, }) => { + const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); const [dataView, setDataView] = useState(null); + const { dataView: experimentalDataView } = useDataView(sourcererScope); const { startTransaction } = useStartTransaction(); - const { indexFieldsSearch } = useDataView(); + const { indexFieldsSearch } = useDataViewOld(); const { dataViewFieldEditor, data: { dataViews }, @@ -61,12 +69,21 @@ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({ const missingPatterns = useSelector((state: State) => { return sourcererSelectors.sourcererScopeMissingPatterns(state, sourcererScope); }); - const selectedDataViewId = useSelector((state: State) => { + const sourcererDataViewId = useSelector((state: State) => { return sourcererSelectors.sourcererScopeSelectedDataViewId(state, sourcererScope); }); + + const selectedDataViewId = useMemo( + () => (newDataViewPickerEnabled ? experimentalDataView?.id : sourcererDataViewId), + [sourcererDataViewId, experimentalDataView?.id, newDataViewPickerEnabled] + ); useEffect(() => { let ignore = false; const fetchAndSetDataView = async (dataViewId: string) => { + if (newDataViewPickerEnabled) { + if (experimentalDataView) setDataView(experimentalDataView); + return; + } const aDatView = await dataViews.get(dataViewId); if (ignore) return; setDataView(aDatView); @@ -78,7 +95,13 @@ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({ return () => { ignore = true; }; - }, [selectedDataViewId, missingPatterns, dataViews]); + }, [ + selectedDataViewId, + missingPatterns, + dataViews, + newDataViewPickerEnabled, + experimentalDataView, + ]); const openFieldEditor = useCallback( async (fieldName) => { @@ -90,7 +113,12 @@ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({ startTransaction({ name: FIELD_BROWSER_ACTIONS.FIELD_SAVED }); // Fetch the updated list of fields // Using cleanCache since the number of fields might have not changed, but we need to update the state anyway - await indexFieldsSearch({ dataViewId: selectedDataViewId, cleanCache: true }); + if (newDataViewPickerEnabled) { + browserFieldsManager.removeFromCache(sourcererScope); + await dataViews.clearInstanceCache(selectedDataViewId); + } else { + await indexFieldsSearch({ dataViewId: selectedDataViewId, cleanCache: true }); + } for (const savedField of savedFields) { if (fieldName && fieldName !== savedField.name) { @@ -129,10 +157,13 @@ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({ selectedDataViewId, dataViewFieldEditor, editorActionsRef, + startTransaction, + newDataViewPickerEnabled, + sourcererScope, + dataViews, indexFieldsSearch, - removeColumn, upsertColumn, - startTransaction, + removeColumn, ] ); @@ -145,9 +176,12 @@ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({ onDelete: async () => { startTransaction({ name: FIELD_BROWSER_ACTIONS.FIELD_DELETED }); - // Fetch the updated list of fields - await indexFieldsSearch({ dataViewId: selectedDataViewId }); - + if (newDataViewPickerEnabled) { + browserFieldsManager.removeFromCache(sourcererScope); + await dataViews.clearInstanceCache(selectedDataViewId); + } else { + await indexFieldsSearch({ dataViewId: selectedDataViewId, cleanCache: true }); + } removeColumn(fieldName); }, }); @@ -157,9 +191,12 @@ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({ dataView, selectedDataViewId, dataViewFieldEditor, - indexFieldsSearch, - removeColumn, startTransaction, + newDataViewPickerEnabled, + removeColumn, + sourcererScope, + dataViews, + indexFieldsSearch, ] );