From 59912e8641effbf8b1eacfda8e83635696cfbfb7 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Mon, 10 Jun 2024 11:23:08 -0300 Subject: [PATCH] [Discover] One Discover context awareness (#183797) ## Summary This PR includes the initial implementation of the Discover contextual awareness framework and composable profiles: ![logs_table](https://github.com/elastic/kibana/assets/25592674/0815687a-c4d8-4a80-8f67-5e1de0c65adf) ### Context We currently support three levels of context in Discover: - Root context: - Based on the current solution type, navigational parameters, etc. - Resolved at application initialization and on parameter changes. - Runs synchronously or asynchronously. - Data source context: - Based on the current ES|QL query or data view. - Resolved on ES|QL query or data view change, before data fetching occurs. - Runs synchronously or asynchronously. - Document context: - Based on individual ES|QL records or ES documents. - Resolved individually for each ES|QL record or ES document after data fetching runs. - Runs synchronously only. ### Composable profiles To support application extensibility based on context, we've introduced the concept of "composable profiles". Composable profiles are implementations of a core `Profile` interface (or a subset of it) containing all of the available extension points Discover supports. A composable profile can be implemented at any context level through a "profile provider", responsible for defining the composable profile and its associated context resolution method. The context resolution method, named `resolve`, determines if its composable profile is a match for the current Discover context, and returns related metadata in a `context` object. ### Merged accessors Composable profiles operate similarly to middleware in that each of their extension point implementations are passed a `prev` argument, which can be called to access the results from profiles at previous context levels, and allows overwriting or composing a final result from the previous results. The method Discover calls to trigger the extension point merging process and obtain a final result from the combined profiles is referred to as a "merged accessor". The following diagram illustrates the extension point merging process: ![image](https://github.com/davismcphee/kibana/assets/25592674/59f7cd23-c1e0-4d8e-99ed-02460211ed96) ### Supporting services The contextual awareness framework is driven by two main supporting services called `ProfileService` and `ProfilesManager`. Each context level has a dedicated profile service, e.g. `RootProfileService`, which is responsible for accepting profile provider registrations and running through each provider in order during context resolution to identify a matching profile. A single `ProfilesManager` is instantiated on Discover load, or one per saved search panel in a dashboard. The profiles manager is responsible for the following: - Managing state associated with the current Discover context. - Coordinating profile services and exposing resolution methods for each context level. - Providing access to the combined set of resolved profiles. - Deduplicating profile resolution attempts with identical parameters. - Error handling and fallback behaviour on profile resolution failure. ### Bringing it all together The following diagram models the overall Discover contextual awareness framework and how each of the above concepts come together: ![image](https://github.com/elastic/kibana/assets/25592674/49193141-c50a-473f-9d38-eb09fbaaffbe) ### Followup work - We'll want to add developer documentation as a followup, which I've created an issue for here: #184698. The summary for this PR can be used as the basis for the documentation. - Since we currently have no profile or extension point implementations, this PR does not include any functional tests. We should create example implementations for functional testing and ensure they're only enabled when running the test suite or when developers want them enabled. I've created a followup issue for this work here: #184699. ### Testing notes Testing the framework is tricky since we have no actual profile or extension point implementations yet. However, I previously added example implementations that I was using for testing while building the framework. I've removed the example implementations so they don't get merged, but they can be temporarily restored for testing by reverting the commit where I removed them: `git revert 5752651f474d99dfbdecfe9d869377b9edaf7c62`. You'll also need to uncomment the following lines in `src/plugins/discover/public/plugin.tsx`: https://github.com/elastic/kibana/blob/ce85a6a35fa3623bfdfac7dae41df2d840394154/src/plugins/discover/public/plugin.tsx#L458-L463 To test the root profile resolution based on solution type, I'd recommend enabling the solution nav locally by adding the following to `kibana.dev.yml`: ```yml xpack.cloud_integrations.experiments.enabled: true xpack.cloud_integrations.experiments.flag_overrides: "solutionNavEnabled": true xpack.cloud.id: "ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=" xpack.cloud.base_url: "https://cloud.elastic.co" xpack.cloud.deployment_url: "/deployments/deploymentId" ``` In order to change the active solution type, modify the `mockSpaceState` in `src/plugins/navigation/public/plugin.tsx`: https://github.com/elastic/kibana/blob/79e51d64f83da6af56107a633a5a3b49947f1ebe/src/plugins/navigation/public/plugin.tsx#L159-L162 For test data, I'd recommend running the following commands to generate sample ECS compliant logs and metrics data: ``` node scripts/synthtrace.js --target "http://elastic:changeme@localhost:9200/" --kibana "http://elastic:changeme@localhost:5601/" --live --logLevel debug simple_logs.ts node scripts/synthtrace.js --target "http://elastic:changeme@localhost:9200/" --kibana "http://elastic:changeme@localhost:5601/" --live --logLevel debug simple_trace.ts ``` And lastly a couple of the ES|QL queries I used for testing: ``` // resolves to the example logs data source context from logs-synth-default // mixed dataset that falls back to vanilla Discover // helpful for testing document context in the doc viewer flyout from logs-synth-default,metrics-* ``` Resolves #181962. ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [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 - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/utils/build_data_record.test.ts | 12 + .../src/utils/build_data_record.ts | 10 +- .../discover/public/__mocks__/services.ts | 3 + .../layout/discover_documents.test.tsx | 23 ++ .../components/layout/discover_documents.tsx | 125 +++++---- .../main/data_fetching/fetch_all.ts | 13 +- .../data_fetching/fetch_documents.test.ts | 11 + .../main/data_fetching/fetch_documents.ts | 4 +- .../main/data_fetching/fetch_esql.test.ts | 66 +++++ .../main/data_fetching/fetch_esql.ts | 48 ++-- .../main/discover_main_route.test.tsx | 27 ++ .../application/main/discover_main_route.tsx | 6 +- .../discover_data_state_container.test.ts | 25 +- .../discover_data_state_container.ts | 8 +- src/plugins/discover/public/build_services.ts | 42 +-- .../discover_container/discover_container.tsx | 1 + .../discover_grid_flyout.test.tsx | 2 + .../context_awareness/__mocks__/index.ts | 100 +++++++ .../composable_profile.test.ts | 67 +++++ .../context_awareness/composable_profile.ts | 28 ++ .../public/context_awareness/hooks/index.ts | 10 + .../hooks/use_profile_accessor.test.ts | 78 ++++++ .../hooks/use_profile_accessor.ts | 25 ++ .../hooks/use_profiles.test.tsx | 91 ++++++ .../context_awareness/hooks/use_profiles.ts | 36 +++ .../hooks/use_root_profile.test.tsx | 45 +++ .../hooks/use_root_profile.ts | 33 +++ .../public/context_awareness/index.ts | 13 + .../context_awareness/profile_service.test.ts | 147 ++++++++++ .../context_awareness/profile_service.ts | 105 +++++++ .../profiles/data_source_profile.ts | 45 +++ .../profiles/document_profile.ts | 41 +++ .../profiles/example_profiles.tsx | 123 ++++++++ .../context_awareness/profiles/index.ts | 11 + .../profiles/root_profile.ts | 42 +++ .../profiles_manager.test.ts | 263 ++++++++++++++++++ .../context_awareness/profiles_manager.ts | 206 ++++++++++++++ .../public/context_awareness/types.ts | 13 + .../__mocks__/customization_context.ts | 10 +- .../public/customizations/defaults.ts | 1 + .../discover/public/customizations/types.ts | 4 + .../saved_search_embeddable.test.ts | 61 +++- .../embeddable/saved_search_embeddable.tsx | 38 ++- .../public/embeddable/saved_search_grid.tsx | 8 + .../view_saved_search_action.test.ts | 5 + .../public/hooks/show_confirm_panel.tsx | 71 ----- src/plugins/discover/public/plugin.tsx | 83 ++++-- .../components/log_ai_assistant/index.tsx | 6 +- .../translations/translations/fr-FR.json | 4 - .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 51 files changed, 2008 insertions(+), 239 deletions(-) create mode 100644 src/plugins/discover/public/application/main/data_fetching/fetch_esql.test.ts create mode 100644 src/plugins/discover/public/context_awareness/__mocks__/index.ts create mode 100644 src/plugins/discover/public/context_awareness/composable_profile.test.ts create mode 100644 src/plugins/discover/public/context_awareness/composable_profile.ts create mode 100644 src/plugins/discover/public/context_awareness/hooks/index.ts create mode 100644 src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts create mode 100644 src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.ts create mode 100644 src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx create mode 100644 src/plugins/discover/public/context_awareness/hooks/use_profiles.ts create mode 100644 src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx create mode 100644 src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts create mode 100644 src/plugins/discover/public/context_awareness/index.ts create mode 100644 src/plugins/discover/public/context_awareness/profile_service.test.ts create mode 100644 src/plugins/discover/public/context_awareness/profile_service.ts create mode 100644 src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts create mode 100644 src/plugins/discover/public/context_awareness/profiles/document_profile.ts create mode 100644 src/plugins/discover/public/context_awareness/profiles/example_profiles.tsx create mode 100644 src/plugins/discover/public/context_awareness/profiles/index.ts create mode 100644 src/plugins/discover/public/context_awareness/profiles/root_profile.ts create mode 100644 src/plugins/discover/public/context_awareness/profiles_manager.test.ts create mode 100644 src/plugins/discover/public/context_awareness/profiles_manager.ts create mode 100644 src/plugins/discover/public/context_awareness/types.ts delete mode 100644 src/plugins/discover/public/hooks/show_confirm_panel.tsx diff --git a/packages/kbn-discover-utils/src/utils/build_data_record.test.ts b/packages/kbn-discover-utils/src/utils/build_data_record.test.ts index ad486aba543a1..e6046d4f5977f 100644 --- a/packages/kbn-discover-utils/src/utils/build_data_record.test.ts +++ b/packages/kbn-discover-utils/src/utils/build_data_record.test.ts @@ -30,5 +30,17 @@ describe('Data table record utils', () => { expect(doc).toHaveProperty('isAnchor'); }); }); + + test('should support processing each record', () => { + const result = buildDataTableRecordList(esHitsMock, dataViewMock, { + processRecord: (record) => ({ ...record, id: 'custom-id' }), + }); + result.forEach((doc) => { + expect(doc).toHaveProperty('id', 'custom-id'); + expect(doc).toHaveProperty('raw'); + expect(doc).toHaveProperty('flattened'); + expect(doc).toHaveProperty('isAnchor'); + }); + }); }); }); diff --git a/packages/kbn-discover-utils/src/utils/build_data_record.ts b/packages/kbn-discover-utils/src/utils/build_data_record.ts index 43adf7b9c8b66..9769201e94aa4 100644 --- a/packages/kbn-discover-utils/src/utils/build_data_record.ts +++ b/packages/kbn-discover-utils/src/utils/build_data_record.ts @@ -35,9 +35,13 @@ export function buildDataTableRecord( * @param docs Array of documents returned from Elasticsearch * @param dataView this current data view */ -export function buildDataTableRecordList( +export function buildDataTableRecordList( docs: EsHitRecord[], - dataView?: DataView + dataView?: DataView, + { processRecord }: { processRecord?: (record: DataTableRecord) => T } = {} ): DataTableRecord[] { - return docs.map((doc) => buildDataTableRecord(doc, dataView)); + return docs.map((doc) => { + const record = buildDataTableRecord(doc, dataView); + return processRecord ? processRecord(record) : record; + }); } diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index e4e2b71de8e74..f75755319a112 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -41,6 +41,7 @@ import { SearchSourceDependencies } from '@kbn/data-plugin/common'; import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import { urlTrackerMock } from './url_tracker.mock'; import { createElement } from 'react'; +import { createContextAwarenessMocks } from '../context_awareness/__mocks__'; export function createDiscoverServicesMock(): DiscoverServices { const dataPlugin = dataPluginMock.createStartContract(); @@ -137,6 +138,7 @@ export function createDiscoverServicesMock(): DiscoverServices { ...uiSettingsMock, }; + const { profilesManagerMock } = createContextAwarenessMocks(); const theme = themeServiceMock.createSetupContract({ darkMode: false }); corePluginMock.theme = theme; @@ -236,6 +238,7 @@ export function createDiscoverServicesMock(): DiscoverServices { contextLocator: { getRedirectUrl: jest.fn(() => '') }, singleDocLocator: { getRedirectUrl: jest.fn(() => '') }, urlTracker: urlTrackerMock, + profilesManager: profilesManagerMock, setHeaderActionMenu: jest.fn(), } as unknown as DiscoverServices; } diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx index ae4b05f495cfa..85c2dd581eecb 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx @@ -31,6 +31,7 @@ const customisationService = createCustomizationService(); async function mountComponent(fetchStatus: FetchStatus, hits: EsHitRecord[]) { const services = discoverServiceMock; + services.data.query.timefilter.timefilter.getTime = () => { return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }; }; @@ -69,6 +70,10 @@ async function mountComponent(fetchStatus: FetchStatus, hits: EsHitRecord[]) { } describe('Discover documents layout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('render loading when loading and no documents', async () => { const component = await mountComponent(FetchStatus.LOADING, []); expect(component.find('.dscDocuments__loading').exists()).toBeTruthy(); @@ -131,4 +136,22 @@ describe('Discover documents layout', () => { expect(discoverGridComponent.prop('externalCustomRenderers')).toBeDefined(); expect(discoverGridComponent.prop('customGridColumnsConfiguration')).toBeDefined(); }); + + describe('context awareness', () => { + it('should pass cell renderers from profile', async () => { + customisationService.set({ + id: 'data_table', + logsEnabled: true, + }); + await discoverServiceMock.profilesManager.resolveRootProfile({ solutionNavId: 'test' }); + const component = await mountComponent(FetchStatus.COMPLETE, esHitsMock); + const discoverGridComponent = component.find(DiscoverGrid); + expect(discoverGridComponent.exists()).toBeTruthy(); + expect(Object.keys(discoverGridComponent.prop('externalCustomRenderers')!)).toEqual([ + 'content', + 'resource', + 'rootProfile', + ]); + }); + }); }); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index ad46b7f3db658..caba229e9137a 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -68,6 +68,7 @@ import { onResizeGridColumn } from '../../../../utils/on_resize_grid_column'; import { useContextualGridCustomisations } from '../../hooks/grid_customisations'; import { useIsEsqlMode } from '../../hooks/use_is_esql_mode'; import { useAdditionalFieldGroups } from '../../hooks/sidebar/use_additional_field_groups'; +import { useProfileAccessor } from '../../../../context_awareness'; const containerStyles = css` position: relative; @@ -263,6 +264,12 @@ function DiscoverDocumentsComponent({ useContextualGridCustomisations() || {}; const additionalFieldGroups = useAdditionalFieldGroups(); + const getCellRenderersAccessor = useProfileAccessor('getCellRenderers'); + const cellRenderers = useMemo(() => { + const getCellRenderers = getCellRenderersAccessor(() => customCellRenderer ?? {}); + return getCellRenderers(); + }, [customCellRenderer, getCellRenderersAccessor]); + const documents = useObservable(stateContainer.dataState.data$.documents$); const callouts = useMemo( @@ -373,66 +380,64 @@ function DiscoverDocumentsComponent({ )} {!isLegacy && ( - <> -
- - - -
- +
+ + + +
)} diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_all.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_all.ts index 410d1d468275d..aed3e6f9a0222 100644 --- a/src/plugins/discover/public/application/main/data_fetching/fetch_all.ts +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_all.ts @@ -64,7 +64,7 @@ export function fetchAll( savedSearch, abortController, } = fetchDeps; - const { data } = services; + const { data, expressions, profilesManager } = services; const searchSource = savedSearch.searchSource.createChild(); try { @@ -100,14 +100,15 @@ export function fetchAll( // Start fetching all required requests const response = isEsqlQuery - ? fetchEsql( + ? fetchEsql({ query, dataView, - data, - services.expressions, + abortSignal: abortController.signal, inspectorAdapters, - abortController.signal - ) + data, + expressions, + profilesManager, + }) : fetchDocuments(searchSource, fetchDeps); const fetchType = isEsqlQuery ? 'fetchTextBased' : 'fetchDocuments'; const startTime = window.performance.now(); diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_documents.test.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_documents.test.ts index 7abc2d2744a60..be1fddf64e87f 100644 --- a/src/plugins/discover/public/application/main/data_fetching/fetch_documents.test.ts +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_documents.test.ts @@ -30,6 +30,10 @@ const getDeps = () => } as unknown as FetchDeps); describe('test fetchDocuments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('resolves with returned documents', async () => { const hits = [ { _id: '1', foo: 'bar' }, @@ -38,10 +42,17 @@ describe('test fetchDocuments', () => { const documents = hits.map((hit) => buildDataTableRecord(hit, dataViewMock)); savedSearchMock.searchSource.fetch$ = () => of({ rawResponse: { hits: { hits } } } as IKibanaSearchResponse>); + const resolveDocumentProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveDocumentProfile' + ); expect(await fetchDocuments(savedSearchMock.searchSource, getDeps())).toEqual({ interceptedWarnings: [], records: documents, }); + expect(resolveDocumentProfileSpy).toHaveBeenCalledTimes(2); + expect(resolveDocumentProfileSpy).toHaveBeenCalledWith({ record: documents[0] }); + expect(resolveDocumentProfileSpy).toHaveBeenCalledWith({ record: documents[1] }); }); test('rejects on query failure', async () => { diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_documents.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_documents.ts index 414d4b3a36587..4ffdd211c0e5e 100644 --- a/src/plugins/discover/public/application/main/data_fetching/fetch_documents.ts +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_documents.ts @@ -67,7 +67,9 @@ export const fetchDocuments = ( .pipe( filter((res) => !isRunningResponse(res)), map((res) => { - return buildDataTableRecordList(res.rawResponse.hits.hits as EsHitRecord[], dataView); + return buildDataTableRecordList(res.rawResponse.hits.hits as EsHitRecord[], dataView, { + processRecord: (record) => services.profilesManager.resolveDocumentProfile({ record }), + }); }) ); diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_esql.test.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_esql.test.ts new file mode 100644 index 0000000000000..6546ae8ffaf2d --- /dev/null +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_esql.test.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { EsHitRecord } from '@kbn/discover-utils'; +import type { ExecutionContract } from '@kbn/expressions-plugin/common'; +import { RequestAdapter } from '@kbn/inspector-plugin/common'; +import { of } from 'rxjs'; +import { dataViewWithTimefieldMock } from '../../../__mocks__/data_view_with_timefield'; +import { discoverServiceMock } from '../../../__mocks__/services'; +import { fetchEsql } from './fetch_esql'; + +describe('fetchEsql', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves with returned records', async () => { + const hits = [ + { _id: '1', foo: 'bar' }, + { _id: '2', foo: 'baz' }, + ] as unknown as EsHitRecord[]; + const records = hits.map((hit, i) => ({ + id: String(i), + raw: hit, + flattened: hit, + })); + const expressionsExecuteSpy = jest.spyOn(discoverServiceMock.expressions, 'execute'); + expressionsExecuteSpy.mockReturnValueOnce({ + cancel: jest.fn(), + getData: jest.fn(() => + of({ + result: { + columns: ['_id', 'foo'], + rows: hits, + }, + }) + ), + } as unknown as ExecutionContract); + const resolveDocumentProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveDocumentProfile' + ); + expect( + await fetchEsql({ + query: { esql: 'from *' }, + dataView: dataViewWithTimefieldMock, + inspectorAdapters: { requests: new RequestAdapter() }, + data: discoverServiceMock.data, + expressions: discoverServiceMock.expressions, + profilesManager: discoverServiceMock.profilesManager, + }) + ).toEqual({ + records, + esqlQueryColumns: ['_id', 'foo'], + esqlHeaderWarning: undefined, + }); + expect(resolveDocumentProfileSpy).toHaveBeenCalledTimes(2); + expect(resolveDocumentProfileSpy).toHaveBeenCalledWith({ record: records[0] }); + expect(resolveDocumentProfileSpy).toHaveBeenCalledWith({ record: records[1] }); + }); +}); diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts index 3aba795d26920..3f54984ae3d3f 100644 --- a/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts @@ -8,15 +8,16 @@ import { pluck } from 'rxjs'; import { lastValueFrom } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import { Query, AggregateQuery, Filter } from '@kbn/es-query'; +import type { Query, AggregateQuery, Filter } from '@kbn/es-query'; import type { Adapters } from '@kbn/inspector-plugin/common'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { Datatable } from '@kbn/expressions-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common'; -import type { DataTableRecord } from '@kbn/discover-utils/types'; +import type { DataTableRecord, EsHitRecord } from '@kbn/discover-utils'; import type { RecordsFetchResponse } from '../../types'; +import type { ProfilesManager } from '../../../context_awareness'; interface EsqlErrorResponse { error: { @@ -25,16 +26,27 @@ interface EsqlErrorResponse { type: 'error'; } -export function fetchEsql( - query: Query | AggregateQuery, - dataView: DataView, - data: DataPublicPluginStart, - expressions: ExpressionsStart, - inspectorAdapters: Adapters, - abortSignal?: AbortSignal, - filters?: Filter[], - inputQuery?: Query -): Promise { +export function fetchEsql({ + query, + inputQuery, + filters, + dataView, + abortSignal, + inspectorAdapters, + data, + expressions, + profilesManager, +}: { + query: Query | AggregateQuery; + inputQuery?: Query; + filters?: Filter[]; + dataView: DataView; + abortSignal?: AbortSignal; + inspectorAdapters: Adapters; + data: DataPublicPluginStart; + expressions: ExpressionsStart; + profilesManager: ProfilesManager; +}): Promise { const timeRange = data.query.timefilter.timefilter.getTime(); return textBasedQueryStateToAstWithValidation({ filters, @@ -69,12 +81,14 @@ export function fetchEsql( const rows = table?.rows ?? []; esqlQueryColumns = table?.columns ?? undefined; esqlHeaderWarning = table.warning ?? undefined; - finalData = rows.map((row: Record, idx: number) => { - return { + finalData = rows.map((row, idx) => { + const record: DataTableRecord = { id: String(idx), - raw: row, + raw: row as EsHitRecord, flattened: row, - } as unknown as DataTableRecord; + }; + + return profilesManager.resolveDocumentProfile({ record }); }); } }); @@ -91,7 +105,7 @@ export function fetchEsql( }); } return { - records: [] as DataTableRecord[], + records: [], esqlQueryColumns: [], esqlHeaderWarning: undefined, }; diff --git a/src/plugins/discover/public/application/main/discover_main_route.test.tsx b/src/plugins/discover/public/application/main/discover_main_route.test.tsx index 496bb91f92cf1..b49abb2fe6685 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.test.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.test.tsx @@ -40,9 +40,22 @@ jest.mock('./discover_main_app', () => { }; }); +let mockRootProfileLoading = false; + +jest.mock('../../context_awareness', () => { + const originalModule = jest.requireActual('../../context_awareness'); + return { + ...originalModule, + useRootProfile: () => ({ + rootProfileLoading: mockRootProfileLoading, + }), + }; +}); + describe('DiscoverMainRoute', () => { beforeEach(() => { mockCustomizationService = createCustomizationService(); + mockRootProfileLoading = false; }); test('renders the main app when hasESData=true & hasUserDataView=true ', async () => { @@ -97,6 +110,20 @@ describe('DiscoverMainRoute', () => { }); }); + test('renders LoadingIndicator while root profile is loading', async () => { + mockRootProfileLoading = true; + const component = mountComponent(true, true); + await waitFor(() => { + component.update(); + expect(component.find(DiscoverMainApp).exists()).toBe(false); + }); + mockRootProfileLoading = false; + await waitFor(() => { + component.setProps({}).update(); + expect(component.find(DiscoverMainApp).exists()).toBe(true); + }); + }); + test('should pass hideNavMenuItems=true to DiscoverTopNavInline while loading', async () => { const component = mountComponent(true, true); expect(component.find(DiscoverTopNavInline).prop('hideNavMenuItems')).toBe(true); diff --git a/src/plugins/discover/public/application/main/discover_main_route.tsx b/src/plugins/discover/public/application/main/discover_main_route.tsx index 560f4cb03535e..f37487b6b93b7 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.tsx @@ -40,6 +40,7 @@ import { import { DiscoverTopNavInline } from './components/top_nav/discover_topnav_inline'; import { DiscoverStateContainer, LoadParams } from './state_management/discover_state'; import { DataSourceType, isDataSourceType } from '../../../common/data_sources'; +import { useRootProfile } from '../../context_awareness'; const DiscoverMainAppMemoized = memo(DiscoverMainApp); @@ -338,11 +339,14 @@ export function DiscoverMainRoute({ stateContainer, ]); + const { solutionNavId } = customizationContext; + const { rootProfileLoading } = useRootProfile({ solutionNavId }); + if (error) { return ; } - if (!customizationService) { + if (!customizationService || rootProfileLoading) { return loadingIndicator; } diff --git a/src/plugins/discover/public/application/main/state_management/discover_data_state_container.test.ts b/src/plugins/discover/public/application/main/state_management/discover_data_state_container.test.ts index 67586670c01c4..05668e0406f9c 100644 --- a/src/plugins/discover/public/application/main/state_management/discover_data_state_container.test.ts +++ b/src/plugins/discover/public/application/main/state_management/discover_data_state_container.test.ts @@ -26,6 +26,10 @@ jest.mock('@kbn/ebt-tools', () => ({ const mockFetchDocuments = fetchDocuments as unknown as jest.MockedFunction; describe('test getDataStateContainer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('return is valid', async () => { const stateContainer = getDiscoverStateMock({ isTimeBased: true }); const dataState = stateContainer.dataState; @@ -35,6 +39,7 @@ describe('test getDataStateContainer', () => { expect(dataState.data$.documents$.getValue().fetchStatus).toBe(FetchStatus.LOADING); expect(dataState.data$.totalHits$.getValue().fetchStatus).toBe(FetchStatus.LOADING); }); + test('refetch$ triggers a search', async () => { const stateContainer = getDiscoverStateMock({ isTimeBased: true }); jest.spyOn(stateContainer.searchSessionManager, 'getNextSearchSessionId'); @@ -46,10 +51,15 @@ describe('test getDataStateContainer', () => { discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => { return { from: '2021-05-01T20:00:00Z', to: '2021-05-02T20:00:00Z' }; }); - const dataState = stateContainer.dataState; + const dataState = stateContainer.dataState; const unsubscribe = dataState.subscribe(); + const resolveDataSourceProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveDataSourceProfile' + ); + expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled(); expect(dataState.data$.totalHits$.value.result).toBe(undefined); expect(dataState.data$.documents$.value.result).toEqual(undefined); @@ -58,6 +68,12 @@ describe('test getDataStateContainer', () => { expect(dataState.data$.main$.value.fetchStatus).toBe('complete'); }); + expect(resolveDataSourceProfileSpy).toHaveBeenCalledTimes(1); + expect(resolveDataSourceProfileSpy).toHaveBeenCalledWith({ + dataSource: stateContainer.appState.get().dataSource, + dataView: stateContainer.savedSearchState.getState().searchSource.getField('index'), + query: stateContainer.appState.get().query, + }); expect(dataState.data$.totalHits$.value.result).toBe(0); expect(dataState.data$.documents$.value.result).toEqual([]); @@ -117,9 +133,13 @@ describe('test getDataStateContainer', () => { ).not.toHaveBeenCalled(); const dataState = stateContainer.dataState; - const unsubscribe = dataState.subscribe(); + const resolveDataSourceProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveDataSourceProfile' + ); + expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled(); expect(dataState.data$.documents$.value.result).toEqual(initialRecords); let hasLoadingMoreStarted = false; @@ -131,6 +151,7 @@ describe('test getDataStateContainer', () => { } if (hasLoadingMoreStarted && value.fetchStatus === FetchStatus.COMPLETE) { + expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled(); expect(value.result).toEqual([...initialRecords, ...moreRecords]); // it uses the same current search session id expect( diff --git a/src/plugins/discover/public/application/main/state_management/discover_data_state_container.ts b/src/plugins/discover/public/application/main/state_management/discover_data_state_container.ts index 71ad2ed87e79b..aaa1f6c15c0f4 100644 --- a/src/plugins/discover/public/application/main/state_management/discover_data_state_container.ts +++ b/src/plugins/discover/public/application/main/state_management/discover_data_state_container.ts @@ -152,7 +152,7 @@ export function getDataStateContainer({ getSavedSearch: () => SavedSearch; setDataView: (dataView: DataView) => void; }): DiscoverDataStateContainer { - const { data, uiSettings, toastNotifications } = services; + const { data, uiSettings, toastNotifications, profilesManager } = services; const { timefilter } = data.query.timefilter; const inspectorAdapters = { requests: new RequestAdapter() }; @@ -249,6 +249,12 @@ export function getDataStateContainer({ return; } + await profilesManager.resolveDataSourceProfile({ + dataSource: getAppState().dataSource, + dataView: getSavedSearch().searchSource.getField('index'), + query: getAppState().query, + }); + abortController = new AbortController(); const prevAutoRefreshDone = autoRefreshDone; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index e3524dcdf115c..519d6a36fb528 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -7,8 +7,7 @@ */ import { History } from 'history'; - -import { +import type { Capabilities, ChromeStart, CoreStart, @@ -24,28 +23,26 @@ import { AppMountParameters, ScopedHistory, } from '@kbn/core/public'; -import { +import type { FilterManager, TimefilterContract, DataViewsContract, DataPublicPluginStart, } from '@kbn/data-plugin/public'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; -import { Start as InspectorPublicPluginStart } from '@kbn/inspector-plugin/public'; -import { SharePluginStart } from '@kbn/share-plugin/public'; -import { ChartsPluginStart } from '@kbn/charts-plugin/public'; -import { UiCounterMetricType } from '@kbn/analytics'; +import type { Start as InspectorPublicPluginStart } from '@kbn/inspector-plugin/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; +import type { UiCounterMetricType } from '@kbn/analytics'; import { Storage } from '@kbn/kibana-utils-plugin/public'; - -import { UrlForwardingStart } from '@kbn/url-forwarding-plugin/public'; -import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; -import { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; -import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; -import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public'; - +import type { UrlForwardingStart } from '@kbn/url-forwarding-plugin/public'; +import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; +import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; +import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import type { EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public'; import type { SpacesApi } from '@kbn/spaces-plugin/public'; -import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public'; @@ -59,10 +56,11 @@ import { memoize, noop } from 'lodash'; import type { NoDataPagePluginStart } from '@kbn/no-data-page-plugin/public'; import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; -import { DiscoverStartPlugins } from './plugin'; -import { DiscoverContextAppLocator } from './application/context/services/locator'; -import { DiscoverSingleDocLocator } from './application/doc/locator'; -import { DiscoverAppLocator } from '../common'; +import type { DiscoverStartPlugins } from './plugin'; +import type { DiscoverContextAppLocator } from './application/context/services/locator'; +import type { DiscoverSingleDocLocator } from './application/doc/locator'; +import type { DiscoverAppLocator } from '../common'; +import type { ProfilesManager } from './context_awareness'; /** * Location state of internal Discover history instance @@ -129,6 +127,7 @@ export interface DiscoverServices { contentClient: ContentClient; noDataPage?: NoDataPagePluginStart; observabilityAIAssistant?: ObservabilityAIAssistantPublicStart; + profilesManager: ProfilesManager; } export const buildServices = memoize( @@ -142,6 +141,7 @@ export const buildServices = memoize( history, scopedHistory, urlTracker, + profilesManager, setHeaderActionMenu = noop, }: { core: CoreStart; @@ -153,6 +153,7 @@ export const buildServices = memoize( history: History; scopedHistory?: ScopedHistory; urlTracker: UrlTracker; + profilesManager: ProfilesManager; setHeaderActionMenu?: AppMountParameters['setHeaderActionMenu']; }): DiscoverServices => { const { usageCollection } = plugins; @@ -212,6 +213,7 @@ export const buildServices = memoize( contentClient: plugins.contentManagement.client, noDataPage: plugins.noDataPage, observabilityAIAssistant: plugins.observabilityAIAssistant, + profilesManager, }; } ); diff --git a/src/plugins/discover/public/components/discover_container/discover_container.tsx b/src/plugins/discover/public/components/discover_container/discover_container.tsx index 4f768554e1e54..c253760fff2ac 100644 --- a/src/plugins/discover/public/components/discover_container/discover_container.tsx +++ b/src/plugins/discover/public/components/discover_container/discover_container.tsx @@ -44,6 +44,7 @@ const discoverContainerWrapperCss = css` `; const customizationContext: DiscoverCustomizationContext = { + solutionNavId: null, displayMode: 'embedded', inlineTopNav: { enabled: false, diff --git a/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx b/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx index 3907bc4999232..cb02e3b736663 100644 --- a/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx +++ b/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx @@ -25,6 +25,7 @@ import { ReactWrapper } from 'enzyme'; import { setUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public/plugin'; import { mockUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public/__mocks__'; import { FlyoutCustomization, useDiscoverCustomization } from '../../customizations'; +import { discoverServiceMock } from '../../__mocks__/services'; const mockFlyoutCustomization: FlyoutCustomization = { id: 'flyout', @@ -76,6 +77,7 @@ describe('Discover flyout', function () { }) => { const onClose = jest.fn(); const services = { + ...discoverServiceMock, filterManager: createFilterManagerMock(), addBasePath: (path: string) => `/base${path}`, history: () => ({ location: {} }), diff --git a/src/plugins/discover/public/context_awareness/__mocks__/index.ts b/src/plugins/discover/public/context_awareness/__mocks__/index.ts new file mode 100644 index 0000000000000..0f8beed5d955f --- /dev/null +++ b/src/plugins/discover/public/context_awareness/__mocks__/index.ts @@ -0,0 +1,100 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getDataTableRecords } from '../../__fixtures__/real_hits'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; +import { + DataSourceCategory, + DataSourceProfileProvider, + DataSourceProfileService, + DocumentProfileProvider, + DocumentProfileService, + DocumentType, + RootProfileProvider, + RootProfileService, + SolutionType, +} from '../profiles'; +import { ProfilesManager } from '../profiles_manager'; + +export const createContextAwarenessMocks = () => { + const rootProfileProviderMock: RootProfileProvider = { + profileId: 'root-profile', + profile: { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + rootProfile: () => 'root-profile', + })), + }, + resolve: jest.fn(() => ({ + isMatch: true, + context: { + solutionType: SolutionType.Observability, + }, + })), + }; + + const dataSourceProfileProviderMock: DataSourceProfileProvider = { + profileId: 'data-source-profile', + profile: { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + rootProfile: () => 'data-source-profile', + })), + }, + resolve: jest.fn(() => ({ + isMatch: true, + context: { + category: DataSourceCategory.Logs, + }, + })), + }; + + const documentProfileProviderMock: DocumentProfileProvider = { + profileId: 'document-profile', + profile: { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + rootProfile: () => 'document-profile', + })), + } as DocumentProfileProvider['profile'], + resolve: jest.fn(() => ({ + isMatch: true, + context: { + type: DocumentType.Log, + }, + })), + }; + + const records = getDataTableRecords(dataViewWithTimefieldMock); + const contextRecordMock = records[0]; + const contextRecordMock2 = records[1]; + + const rootProfileServiceMock = new RootProfileService(); + rootProfileServiceMock.registerProvider(rootProfileProviderMock); + + const dataSourceProfileServiceMock = new DataSourceProfileService(); + dataSourceProfileServiceMock.registerProvider(dataSourceProfileProviderMock); + + const documentProfileServiceMock = new DocumentProfileService(); + documentProfileServiceMock.registerProvider(documentProfileProviderMock); + + const profilesManagerMock = new ProfilesManager( + rootProfileServiceMock, + dataSourceProfileServiceMock, + documentProfileServiceMock + ); + + return { + rootProfileProviderMock, + dataSourceProfileProviderMock, + documentProfileProviderMock, + contextRecordMock, + contextRecordMock2, + profilesManagerMock, + }; +}; diff --git a/src/plugins/discover/public/context_awareness/composable_profile.test.ts b/src/plugins/discover/public/context_awareness/composable_profile.test.ts new file mode 100644 index 0000000000000..251da37fa0126 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/composable_profile.test.ts @@ -0,0 +1,67 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ComposableProfile, getMergedAccessor } from './composable_profile'; +import { Profile } from './types'; + +describe('getMergedAccessor', () => { + it('should return the base implementation if no profiles are provided', () => { + const baseImpl: Profile['getCellRenderers'] = jest.fn(() => ({ base: jest.fn() })); + const mergedAccessor = getMergedAccessor([], 'getCellRenderers', baseImpl); + const result = mergedAccessor(); + expect(baseImpl).toHaveBeenCalled(); + expect(result).toEqual({ base: expect.any(Function) }); + }); + + it('should merge the accessors in the correct order', () => { + const baseImpl: Profile['getCellRenderers'] = jest.fn(() => ({ base: jest.fn() })); + const profile1: ComposableProfile = { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + profile1: jest.fn(), + })), + }; + const profile2: ComposableProfile = { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + profile2: jest.fn(), + })), + }; + const mergedAccessor = getMergedAccessor([profile1, profile2], 'getCellRenderers', baseImpl); + const result = mergedAccessor(); + expect(baseImpl).toHaveBeenCalled(); + expect(profile1.getCellRenderers).toHaveBeenCalled(); + expect(profile2.getCellRenderers).toHaveBeenCalled(); + expect(result).toEqual({ + base: expect.any(Function), + profile1: expect.any(Function), + profile2: expect.any(Function), + }); + expect(Object.keys(result)).toEqual(['base', 'profile1', 'profile2']); + }); + + it('should allow overwriting previous accessors', () => { + const baseImpl: Profile['getCellRenderers'] = jest.fn(() => ({ base: jest.fn() })); + const profile1: ComposableProfile = { + getCellRenderers: jest.fn(() => () => ({ profile1: jest.fn() })), + }; + const profile2: ComposableProfile = { + getCellRenderers: jest.fn((prev) => () => ({ + ...prev(), + profile2: jest.fn(), + })), + }; + const mergedAccessor = getMergedAccessor([profile1, profile2], 'getCellRenderers', baseImpl); + const result = mergedAccessor(); + expect(baseImpl).not.toHaveBeenCalled(); + expect(profile1.getCellRenderers).toHaveBeenCalled(); + expect(profile2.getCellRenderers).toHaveBeenCalled(); + expect(result).toEqual({ profile1: expect.any(Function), profile2: expect.any(Function) }); + expect(Object.keys(result)).toEqual(['profile1', 'profile2']); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/composable_profile.ts b/src/plugins/discover/public/context_awareness/composable_profile.ts new file mode 100644 index 0000000000000..c2211dee3f370 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/composable_profile.ts @@ -0,0 +1,28 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Profile } from './types'; + +export type PartialProfile = Partial; + +export type ComposableAccessor = (getPrevious: T) => T; + +export type ComposableProfile = { + [TKey in keyof TProfile]?: ComposableAccessor; +}; + +export const getMergedAccessor = ( + profiles: ComposableProfile[], + key: TKey, + baseImpl: Profile[TKey] +) => { + return profiles.reduce((nextAccessor, profile) => { + const currentAccessor = profile[key]; + return currentAccessor ? currentAccessor(nextAccessor) : nextAccessor; + }, baseImpl); +}; diff --git a/src/plugins/discover/public/context_awareness/hooks/index.ts b/src/plugins/discover/public/context_awareness/hooks/index.ts new file mode 100644 index 0000000000000..3235844de4fc5 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/index.ts @@ -0,0 +1,10 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { useProfileAccessor } from './use_profile_accessor'; +export { useRootProfile } from './use_root_profile'; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts b/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts new file mode 100644 index 0000000000000..7f3cd816ae9e8 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.test.ts @@ -0,0 +1,78 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { ComposableProfile, getMergedAccessor } from '../composable_profile'; +import { useProfileAccessor } from './use_profile_accessor'; +import { getDataTableRecords } from '../../__fixtures__/real_hits'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; +import { useProfiles } from './use_profiles'; + +let mockProfiles: ComposableProfile[] = []; + +jest.mock('./use_profiles', () => ({ + useProfiles: jest.fn(() => mockProfiles), +})); + +jest.mock('../composable_profile', () => { + const originalModule = jest.requireActual('../composable_profile'); + return { + ...originalModule, + getMergedAccessor: jest.fn(originalModule.getMergedAccessor), + }; +}); + +const record = getDataTableRecords(dataViewWithTimefieldMock)[0]; + +describe('useProfileAccessor', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockProfiles = [ + { getCellRenderers: (prev) => () => ({ ...prev(), profile1: jest.fn() }) }, + { getCellRenderers: (prev) => () => ({ ...prev(), profile2: jest.fn() }) }, + ]; + }); + + it('should return a function that merges accessors', () => { + const { result } = renderHook(() => useProfileAccessor('getCellRenderers', { record })); + expect(useProfiles).toHaveBeenCalledTimes(1); + expect(useProfiles).toHaveBeenCalledWith({ record }); + const base = () => ({ base: jest.fn() }); + const accessor = result.current(base); + expect(getMergedAccessor).toHaveBeenCalledTimes(1); + expect(getMergedAccessor).toHaveBeenCalledWith(mockProfiles, 'getCellRenderers', base); + const renderers = accessor(); + expect(renderers).toEqual({ + base: expect.any(Function), + profile1: expect.any(Function), + profile2: expect.any(Function), + }); + expect(Object.keys(renderers)).toEqual(['base', 'profile1', 'profile2']); + }); + + it('should recalculate the accessor when the key changes', () => { + const { rerender, result } = renderHook(({ key }) => useProfileAccessor(key, { record }), { + initialProps: { key: 'getCellRenderers' as const }, + }); + const prevResult = result.current; + rerender({ key: 'getCellRenderers' }); + expect(result.current).toBe(prevResult); + rerender({ key: 'otherKey' as unknown as 'getCellRenderers' }); + expect(result.current).not.toBe(prevResult); + }); + + it('should recalculate the accessor when the profiles change', () => { + const { rerender, result } = renderHook(() => + useProfileAccessor('getCellRenderers', { record }) + ); + const prevResult = result.current; + mockProfiles = [{ getCellRenderers: (prev) => () => ({ ...prev(), profile3: jest.fn() }) }]; + rerender(); + expect(result.current).not.toBe(prevResult); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.ts b/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.ts new file mode 100644 index 0000000000000..58c5a316f86cf --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_profile_accessor.ts @@ -0,0 +1,25 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useCallback } from 'react'; +import { getMergedAccessor } from '../composable_profile'; +import type { GetProfilesOptions } from '../profiles_manager'; +import { useProfiles } from './use_profiles'; +import type { Profile } from '../types'; + +export const useProfileAccessor = ( + key: TKey, + options: GetProfilesOptions = {} +) => { + const profiles = useProfiles(options); + + return useCallback( + (baseImpl: Profile[TKey]) => getMergedAccessor(profiles, key, baseImpl), + [key, profiles] + ); +}; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx b/src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx new file mode 100644 index 0000000000000..f8613e4fea380 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_profiles.test.tsx @@ -0,0 +1,91 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { discoverServiceMock } from '../../__mocks__/services'; +import { GetProfilesOptions } from '../profiles_manager'; +import { createContextAwarenessMocks } from '../__mocks__'; +import { useProfiles } from './use_profiles'; + +const { + rootProfileProviderMock, + dataSourceProfileProviderMock, + documentProfileProviderMock, + contextRecordMock, + contextRecordMock2, + profilesManagerMock, +} = createContextAwarenessMocks(); + +profilesManagerMock.resolveRootProfile({}); +profilesManagerMock.resolveDataSourceProfile({}); + +const record = profilesManagerMock.resolveDocumentProfile({ record: contextRecordMock }); +const record2 = profilesManagerMock.resolveDocumentProfile({ record: contextRecordMock2 }); + +discoverServiceMock.profilesManager = profilesManagerMock; + +const getProfilesSpy = jest.spyOn(discoverServiceMock.profilesManager, 'getProfiles'); +const getProfiles$Spy = jest.spyOn(discoverServiceMock.profilesManager, 'getProfiles$'); + +const render = () => { + return renderHook((props) => useProfiles(props), { + initialProps: { record } as GetProfilesOptions, + wrapper: ({ children }) => ( + {children} + ), + }); +}; + +describe('useProfiles', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return profiles', () => { + const { result } = render(); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + expect(result.current).toEqual([ + rootProfileProviderMock.profile, + dataSourceProfileProviderMock.profile, + documentProfileProviderMock.profile, + ]); + }); + + it('should return the same array reference if profiles do not change', () => { + const { result, rerender } = render(); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + const prevResult = result.current; + rerender({ record }); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + expect(result.current).toBe(prevResult); + rerender({ record: record2 }); + expect(getProfilesSpy).toHaveBeenCalledTimes(3); + expect(getProfiles$Spy).toHaveBeenCalledTimes(2); + expect(result.current).toBe(prevResult); + }); + + it('should return a different array reference if profiles change', () => { + const { result, rerender } = render(); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + const prevResult = result.current; + rerender({ record }); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(getProfiles$Spy).toHaveBeenCalledTimes(1); + expect(result.current).toBe(prevResult); + rerender({ record: undefined }); + expect(getProfilesSpy).toHaveBeenCalledTimes(3); + expect(getProfiles$Spy).toHaveBeenCalledTimes(2); + expect(result.current).not.toBe(prevResult); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/hooks/use_profiles.ts b/src/plugins/discover/public/context_awareness/hooks/use_profiles.ts new file mode 100644 index 0000000000000..9bd86e4386150 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_profiles.ts @@ -0,0 +1,36 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { useDiscoverServices } from '../../hooks/use_discover_services'; +import type { GetProfilesOptions } from '../profiles_manager'; + +export const useProfiles = ({ record }: GetProfilesOptions = {}) => { + const { profilesManager } = useDiscoverServices(); + const [profiles, setProfiles] = useState(() => profilesManager.getProfiles({ record })); + const profiles$ = useMemo( + () => profilesManager.getProfiles$({ record }), + [profilesManager, record] + ); + + useEffect(() => { + const subscription = profiles$.subscribe((newProfiles) => { + setProfiles((currentProfiles) => { + return currentProfiles.every((profile, i) => profile === newProfiles[i]) + ? currentProfiles + : newProfiles; + }); + }); + + return () => { + subscription.unsubscribe(); + }; + }, [profiles$]); + + return profiles; +}; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx new file mode 100644 index 0000000000000..a41ec7c23cf88 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { discoverServiceMock } from '../../__mocks__/services'; +import { useRootProfile } from './use_root_profile'; + +const render = () => { + return renderHook((props) => useRootProfile(props), { + initialProps: { solutionNavId: 'solutionNavId' }, + wrapper: ({ children }) => ( + {children} + ), + }); +}; + +describe('useRootProfile', () => { + it('should return rootProfileLoading as true', () => { + const { result } = render(); + expect(result.current.rootProfileLoading).toBe(true); + }); + + it('should return rootProfileLoading as false', async () => { + const { result, waitForNextUpdate } = render(); + await waitForNextUpdate(); + expect(result.current.rootProfileLoading).toBe(false); + }); + + it('should return rootProfileLoading as true when solutionNavId changes', async () => { + const { result, rerender, waitForNextUpdate } = render(); + await waitForNextUpdate(); + expect(result.current.rootProfileLoading).toBe(false); + rerender({ solutionNavId: 'newSolutionNavId' }); + expect(result.current.rootProfileLoading).toBe(true); + await waitForNextUpdate(); + expect(result.current.rootProfileLoading).toBe(false); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts new file mode 100644 index 0000000000000..ff2d7edbcefb8 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_root_profile.ts @@ -0,0 +1,33 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useEffect, useState } from 'react'; +import { useDiscoverServices } from '../../hooks/use_discover_services'; + +export const useRootProfile = ({ solutionNavId }: { solutionNavId: string | null }) => { + const { profilesManager } = useDiscoverServices(); + const [rootProfileLoading, setRootProfileLoading] = useState(true); + + useEffect(() => { + let aborted = false; + + setRootProfileLoading(true); + + profilesManager.resolveRootProfile({ solutionNavId }).then(() => { + if (!aborted) { + setRootProfileLoading(false); + } + }); + + return () => { + aborted = true; + }; + }, [profilesManager, solutionNavId]); + + return { rootProfileLoading }; +}; diff --git a/src/plugins/discover/public/context_awareness/index.ts b/src/plugins/discover/public/context_awareness/index.ts new file mode 100644 index 0000000000000..6106d9d154e49 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/index.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './types'; +export * from './profiles'; +export { getMergedAccessor } from './composable_profile'; +export { ProfilesManager } from './profiles_manager'; +export { useProfileAccessor, useRootProfile } from './hooks'; diff --git a/src/plugins/discover/public/context_awareness/profile_service.test.ts b/src/plugins/discover/public/context_awareness/profile_service.test.ts new file mode 100644 index 0000000000000..e306ee149f52f --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profile_service.test.ts @@ -0,0 +1,147 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable max-classes-per-file */ + +import { AsyncProfileService, ContextWithProfileId, ProfileService } from './profile_service'; +import { Profile } from './types'; + +interface TestParams { + myParam: string; +} + +interface TestContext { + myContext: string; +} + +const defaultContext: ContextWithProfileId = { + profileId: 'test-profile', + myContext: 'test', +}; + +class TestProfileService extends ProfileService { + constructor() { + super(defaultContext); + } +} + +type TestProfileProvider = Parameters[0]; + +class TestAsyncProfileService extends AsyncProfileService { + constructor() { + super(defaultContext); + } +} + +type TestAsyncProfileProvider = Parameters[0]; + +const provider: TestProfileProvider = { + profileId: 'test-profile-1', + profile: { getCellRenderers: jest.fn() }, + resolve: jest.fn(() => ({ isMatch: false })), +}; + +const provider2: TestProfileProvider = { + profileId: 'test-profile-2', + profile: { getCellRenderers: jest.fn() }, + resolve: jest.fn(({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })), +}; + +const provider3: TestProfileProvider = { + profileId: 'test-profile-3', + profile: { getCellRenderers: jest.fn() }, + resolve: jest.fn(({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })), +}; + +const asyncProvider2: TestAsyncProfileProvider = { + profileId: 'test-profile-2', + profile: { getCellRenderers: jest.fn() }, + resolve: jest.fn(async ({ myParam }) => ({ isMatch: true, context: { myContext: myParam } })), +}; + +describe('ProfileService', () => { + let service: TestProfileService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new TestProfileService(); + }); + + it('should expose defaultContext', () => { + expect(service.defaultContext).toBe(defaultContext); + }); + + it('should allow registering providers and getting profiles', () => { + service.registerProvider(provider); + service.registerProvider(provider2); + expect(service.getProfile({ profileId: 'test-profile-1', myContext: 'test' })).toBe( + provider.profile + ); + expect(service.getProfile({ profileId: 'test-profile-2', myContext: 'test' })).toBe( + provider2.profile + ); + }); + + it('should return empty profile if no provider is found', () => { + service.registerProvider(provider); + expect(service.getProfile({ profileId: 'test-profile-2', myContext: 'test' })).toEqual({}); + }); + + it('should resolve to first matching context', () => { + service.registerProvider(provider); + service.registerProvider(provider2); + service.registerProvider(provider3); + expect(service.resolve({ myParam: 'test' })).toEqual({ + profileId: 'test-profile-2', + myContext: 'test', + }); + expect(provider.resolve).toHaveBeenCalledTimes(1); + expect(provider.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + expect(provider2.resolve).toHaveBeenCalledTimes(1); + expect(provider2.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + expect(provider3.resolve).not.toHaveBeenCalled(); + }); + + it('should resolve to default context if no matching context is found', () => { + service.registerProvider(provider); + expect(service.resolve({ myParam: 'test' })).toEqual(defaultContext); + expect(provider.resolve).toHaveBeenCalledTimes(1); + expect(provider.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + }); +}); + +describe('AsyncProfileService', () => { + let service: TestAsyncProfileService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new TestAsyncProfileService(); + }); + + it('should resolve to first matching context', async () => { + service.registerProvider(provider); + service.registerProvider(asyncProvider2); + service.registerProvider(provider3); + await expect(service.resolve({ myParam: 'test' })).resolves.toEqual({ + profileId: 'test-profile-2', + myContext: 'test', + }); + expect(provider.resolve).toHaveBeenCalledTimes(1); + expect(provider.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + expect(asyncProvider2.resolve).toHaveBeenCalledTimes(1); + expect(asyncProvider2.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + expect(provider3.resolve).not.toHaveBeenCalled(); + }); + + it('should resolve to default context if no matching context is found', async () => { + service.registerProvider(provider); + await expect(service.resolve({ myParam: 'test' })).resolves.toEqual(defaultContext); + expect(provider.resolve).toHaveBeenCalledTimes(1); + expect(provider.resolve).toHaveBeenCalledWith({ myParam: 'test' }); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/profile_service.ts b/src/plugins/discover/public/context_awareness/profile_service.ts new file mode 100644 index 0000000000000..2b43595761d19 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profile_service.ts @@ -0,0 +1,105 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable max-classes-per-file */ + +import type { ComposableProfile, PartialProfile } from './composable_profile'; +import type { Profile } from './types'; + +export type ResolveProfileResult = + | { isMatch: true; context: TContext } + | { isMatch: false }; + +export type ProfileProviderMode = 'sync' | 'async'; + +export interface ProfileProvider< + TProfile extends PartialProfile, + TParams, + TContext, + TMode extends ProfileProviderMode +> { + profileId: string; + profile: ComposableProfile; + resolve: ( + params: TParams + ) => TMode extends 'sync' + ? ResolveProfileResult + : ResolveProfileResult | Promise>; +} + +export type ContextWithProfileId = TContext & { profileId: string }; + +const EMPTY_PROFILE = {}; + +abstract class BaseProfileService< + TProfile extends PartialProfile, + TParams, + TContext, + TMode extends ProfileProviderMode +> { + protected readonly providers: Array> = []; + + protected constructor(public readonly defaultContext: ContextWithProfileId) {} + + public registerProvider(provider: ProfileProvider) { + this.providers.push(provider); + } + + public getProfile(context: ContextWithProfileId): ComposableProfile { + const provider = this.providers.find((current) => current.profileId === context.profileId); + return provider?.profile ?? EMPTY_PROFILE; + } + + public abstract resolve( + params: TParams + ): TMode extends 'sync' + ? ContextWithProfileId + : Promise>; +} + +export class ProfileService< + TProfile extends PartialProfile, + TParams, + TContext +> extends BaseProfileService { + public resolve(params: TParams) { + for (const provider of this.providers) { + const result = provider.resolve(params); + + if (result.isMatch) { + return { + ...result.context, + profileId: provider.profileId, + }; + } + } + + return this.defaultContext; + } +} + +export class AsyncProfileService< + TProfile extends PartialProfile, + TParams, + TContext +> extends BaseProfileService { + public async resolve(params: TParams) { + for (const provider of this.providers) { + const result = await provider.resolve(params); + + if (result.isMatch) { + return { + ...result.context, + profileId: provider.profileId, + }; + } + } + + return this.defaultContext; + } +} diff --git a/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts b/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts new file mode 100644 index 0000000000000..f616fef913259 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles/data_source_profile.ts @@ -0,0 +1,45 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { AggregateQuery, Query } from '@kbn/es-query'; +import type { DiscoverDataSource } from '../../../common/data_sources'; +import { AsyncProfileService } from '../profile_service'; +import { Profile } from '../types'; + +export enum DataSourceCategory { + Logs = 'logs', + Default = 'default', +} + +export interface DataSourceProfileProviderParams { + dataSource?: DiscoverDataSource; + dataView?: DataView; + query?: Query | AggregateQuery; +} + +export interface DataSourceContext { + category: DataSourceCategory; +} + +export type DataSourceProfile = Profile; + +export class DataSourceProfileService extends AsyncProfileService< + DataSourceProfile, + DataSourceProfileProviderParams, + DataSourceContext +> { + constructor() { + super({ + profileId: 'default-data-source-profile', + category: DataSourceCategory.Default, + }); + } +} + +export type DataSourceProfileProvider = Parameters[0]; diff --git a/src/plugins/discover/public/context_awareness/profiles/document_profile.ts b/src/plugins/discover/public/context_awareness/profiles/document_profile.ts new file mode 100644 index 0000000000000..70b134da452e4 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles/document_profile.ts @@ -0,0 +1,41 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataTableRecord } from '@kbn/discover-utils'; +import type { Profile } from '../types'; +import { ProfileService } from '../profile_service'; + +export enum DocumentType { + Log = 'log', + Default = 'default', +} + +export interface DocumentProfileProviderParams { + record: DataTableRecord; +} + +export interface DocumentContext { + type: DocumentType; +} + +export type DocumentProfile = Omit; + +export class DocumentProfileService extends ProfileService< + DocumentProfile, + DocumentProfileProviderParams, + DocumentContext +> { + constructor() { + super({ + profileId: 'default-document-profile', + type: DocumentType.Default, + }); + } +} + +export type DocumentProfileProvider = Parameters[0]; diff --git a/src/plugins/discover/public/context_awareness/profiles/example_profiles.tsx b/src/plugins/discover/public/context_awareness/profiles/example_profiles.tsx new file mode 100644 index 0000000000000..3835337b25304 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles/example_profiles.tsx @@ -0,0 +1,123 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiBadge } from '@elastic/eui'; +import { + DataTableRecord, + getMessageFieldWithFallbacks, + LogDocumentOverview, +} from '@kbn/discover-utils'; +import { isOfAggregateQueryType } from '@kbn/es-query'; +import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { capitalize } from 'lodash'; +import React from 'react'; +import { DataSourceType, isDataSourceType } from '../../../common/data_sources'; +import { DataSourceCategory, DataSourceProfileProvider } from './data_source_profile'; +import { DocumentProfileProvider, DocumentType } from './document_profile'; +import { RootProfileProvider, SolutionType } from './root_profile'; + +export const o11yRootProfileProvider: RootProfileProvider = { + profileId: 'o11y-root-profile', + profile: {}, + resolve: (params) => { + if (params.solutionNavId === 'oblt') { + return { + isMatch: true, + context: { + solutionType: SolutionType.Observability, + }, + }; + } + + return { isMatch: false }; + }, +}; + +export const logsDataSourceProfileProvider: DataSourceProfileProvider = { + profileId: 'logs-data-source-profile', + profile: { + getCellRenderers: (prev) => () => ({ + ...prev(), + '@timestamp': (props) => { + const timestamp = getFieldValue(props.row, '@timestamp'); + return ( + + {timestamp} + + ); + }, + 'log.level': (props) => { + const level = getFieldValue(props.row, 'log.level'); + if (!level) { + return (None); + } + const levelMap: Record = { + info: 'primary', + debug: 'default', + error: 'danger', + }; + return ( + + {capitalize(level)} + + ); + }, + message: (props) => { + const { value } = getMessageFieldWithFallbacks( + props.row.flattened as unknown as LogDocumentOverview + ); + return value || (None); + }, + }), + }, + resolve: (params) => { + let indices: string[] = []; + + if (isDataSourceType(params.dataSource, DataSourceType.Esql)) { + if (!isOfAggregateQueryType(params.query)) { + return { isMatch: false }; + } + + indices = getIndexPatternFromESQLQuery(params.query.esql).split(','); + } else if (isDataSourceType(params.dataSource, DataSourceType.DataView) && params.dataView) { + indices = params.dataView.getIndexPattern().split(','); + } + + if (indices.every((index) => index.startsWith('logs-'))) { + return { + isMatch: true, + context: { category: DataSourceCategory.Logs }, + }; + } + + return { isMatch: false }; + }, +}; + +export const logDocumentProfileProvider: DocumentProfileProvider = { + profileId: 'log-document-profile', + profile: {}, + resolve: (params) => { + if (getFieldValue(params.record, 'data_stream.type') === 'logs') { + return { + isMatch: true, + context: { + type: DocumentType.Log, + }, + }; + } + + return { isMatch: false }; + }, +}; + +const getFieldValue = (record: DataTableRecord, field: string) => { + const value = record.flattened[field]; + return Array.isArray(value) ? value[0] : value; +}; diff --git a/src/plugins/discover/public/context_awareness/profiles/index.ts b/src/plugins/discover/public/context_awareness/profiles/index.ts new file mode 100644 index 0000000000000..f661276b4a04c --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles/index.ts @@ -0,0 +1,11 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './root_profile'; +export * from './data_source_profile'; +export * from './document_profile'; diff --git a/src/plugins/discover/public/context_awareness/profiles/root_profile.ts b/src/plugins/discover/public/context_awareness/profiles/root_profile.ts new file mode 100644 index 0000000000000..42497fe680c5c --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles/root_profile.ts @@ -0,0 +1,42 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Profile } from '../types'; +import { AsyncProfileService } from '../profile_service'; + +export enum SolutionType { + Observability = 'oblt', + Security = 'security', + Search = 'search', + Default = 'default', +} + +export interface RootProfileProviderParams { + solutionNavId?: string | null; +} + +export interface RootContext { + solutionType: SolutionType; +} + +export type RootProfile = Profile; + +export class RootProfileService extends AsyncProfileService< + RootProfile, + RootProfileProviderParams, + RootContext +> { + constructor() { + super({ + profileId: 'default-root-profile', + solutionType: SolutionType.Default, + }); + } +} + +export type RootProfileProvider = Parameters[0]; diff --git a/src/plugins/discover/public/context_awareness/profiles_manager.test.ts b/src/plugins/discover/public/context_awareness/profiles_manager.test.ts new file mode 100644 index 0000000000000..153ef979aabba --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles_manager.test.ts @@ -0,0 +1,263 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { firstValueFrom, Subject } from 'rxjs'; +import { createEsqlDataSource } from '../../common/data_sources'; +import { addLog } from '../utils/add_log'; +import { createContextAwarenessMocks } from './__mocks__'; + +jest.mock('../utils/add_log'); + +let mocks = createContextAwarenessMocks(); + +describe('ProfilesManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + mocks = createContextAwarenessMocks(); + }); + + it('should return default profiles', () => { + const profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([{}, {}, {}]); + }); + + it('should resolve root profile', async () => { + await mocks.profilesManagerMock.resolveRootProfile({}); + const profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([mocks.rootProfileProviderMock.profile, {}, {}]); + }); + + it('should resolve data source profile', async () => { + await mocks.profilesManagerMock.resolveDataSourceProfile({}); + const profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([{}, mocks.dataSourceProfileProviderMock.profile, {}]); + }); + + it('should resolve document profile', async () => { + const record = mocks.profilesManagerMock.resolveDocumentProfile({ + record: mocks.contextRecordMock, + }); + const profiles = mocks.profilesManagerMock.getProfiles({ record }); + expect(profiles).toEqual([{}, {}, mocks.documentProfileProviderMock.profile]); + }); + + it('should resolve multiple profiles', async () => { + await mocks.profilesManagerMock.resolveRootProfile({}); + await mocks.profilesManagerMock.resolveDataSourceProfile({}); + const record = mocks.profilesManagerMock.resolveDocumentProfile({ + record: mocks.contextRecordMock, + }); + const profiles = mocks.profilesManagerMock.getProfiles({ record }); + expect(profiles).toEqual([ + mocks.rootProfileProviderMock.profile, + mocks.dataSourceProfileProviderMock.profile, + mocks.documentProfileProviderMock.profile, + ]); + }); + + it('should expose profiles as an observable', async () => { + const getProfilesSpy = jest.spyOn(mocks.profilesManagerMock, 'getProfiles'); + const record = mocks.profilesManagerMock.resolveDocumentProfile({ + record: mocks.contextRecordMock, + }); + const profiles$ = mocks.profilesManagerMock.getProfiles$({ record }); + const next = jest.fn(); + profiles$.subscribe(next); + expect(getProfilesSpy).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith([{}, {}, mocks.documentProfileProviderMock.profile]); + await mocks.profilesManagerMock.resolveRootProfile({}); + expect(getProfilesSpy).toHaveBeenCalledTimes(2); + expect(next).toHaveBeenCalledWith([ + mocks.rootProfileProviderMock.profile, + {}, + mocks.documentProfileProviderMock.profile, + ]); + await mocks.profilesManagerMock.resolveDataSourceProfile({}); + expect(getProfilesSpy).toHaveBeenCalledTimes(3); + expect(next).toHaveBeenCalledWith([ + mocks.rootProfileProviderMock.profile, + mocks.dataSourceProfileProviderMock.profile, + mocks.documentProfileProviderMock.profile, + ]); + }); + + it("should not resolve root profile again if params haven't changed", async () => { + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'solutionNavId' }); + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'solutionNavId' }); + expect(mocks.rootProfileProviderMock.resolve).toHaveBeenCalledTimes(1); + }); + + it('should resolve root profile again if params have changed', async () => { + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'solutionNavId' }); + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'newSolutionNavId' }); + expect(mocks.rootProfileProviderMock.resolve).toHaveBeenCalledTimes(2); + }); + + it('should not resolve data source profile again if params have not changed', async () => { + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + expect(mocks.dataSourceProfileProviderMock.resolve).toHaveBeenCalledTimes(1); + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + expect(mocks.dataSourceProfileProviderMock.resolve).toHaveBeenCalledTimes(1); + }); + + it('should resolve data source profile again if params have changed', async () => { + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + expect(mocks.dataSourceProfileProviderMock.resolve).toHaveBeenCalledTimes(1); + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from logs-*' }, + }); + expect(mocks.dataSourceProfileProviderMock.resolve).toHaveBeenCalledTimes(2); + }); + + it('should log an error and fall back to the default profile if root profile resolution fails', async () => { + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'solutionNavId' }); + let profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([mocks.rootProfileProviderMock.profile, {}, {}]); + const resolveSpy = jest.spyOn(mocks.rootProfileProviderMock, 'resolve'); + resolveSpy.mockRejectedValue(new Error('Failed to resolve')); + await mocks.profilesManagerMock.resolveRootProfile({ solutionNavId: 'newSolutionNavId' }); + expect(addLog).toHaveBeenCalledWith( + '[ProfilesManager] root context resolution failed with params: {\n "solutionNavId": "newSolutionNavId"\n}', + new Error('Failed to resolve') + ); + profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([{}, {}, {}]); + }); + + it('should log an error and fall back to the default profile if data source profile resolution fails', async () => { + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + let profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([{}, mocks.dataSourceProfileProviderMock.profile, {}]); + const resolveSpy = jest.spyOn(mocks.dataSourceProfileProviderMock, 'resolve'); + resolveSpy.mockRejectedValue(new Error('Failed to resolve')); + await mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from logs-*' }, + }); + expect(addLog).toHaveBeenCalledWith( + '[ProfilesManager] data source context resolution failed with params: {\n "esqlQuery": "from logs-*"\n}', + new Error('Failed to resolve') + ); + profiles = mocks.profilesManagerMock.getProfiles(); + expect(profiles).toEqual([{}, {}, {}]); + }); + + it('should log an error and fall back to the default profile if document profile resolution fails', () => { + const record = mocks.profilesManagerMock.resolveDocumentProfile({ + record: mocks.contextRecordMock, + }); + let profiles = mocks.profilesManagerMock.getProfiles({ record }); + expect(profiles).toEqual([{}, {}, mocks.documentProfileProviderMock.profile]); + const resolveSpy = jest.spyOn(mocks.documentProfileProviderMock, 'resolve'); + resolveSpy.mockImplementation(() => { + throw new Error('Failed to resolve'); + }); + const record2 = mocks.profilesManagerMock.resolveDocumentProfile({ + record: mocks.contextRecordMock2, + }); + profiles = mocks.profilesManagerMock.getProfiles({ record: record2 }); + expect(addLog).toHaveBeenCalledWith( + '[ProfilesManager] document context resolution failed with params: {\n "recordId": "logstash-2014.09.09::388::"\n}', + new Error('Failed to resolve') + ); + expect(profiles).toEqual([{}, {}, {}]); + }); + + it('should cancel existing root profile resolution when another is triggered', async () => { + const context = await mocks.rootProfileProviderMock.resolve({ solutionNavId: 'solutionNavId' }); + const newContext = await mocks.rootProfileProviderMock.resolve({ + solutionNavId: 'newSolutionNavId', + }); + const resolveSpy = jest.spyOn(mocks.rootProfileProviderMock, 'resolve'); + resolveSpy.mockClear(); + const resolvedDeferredResult$ = new Subject(); + const deferredResult = firstValueFrom(resolvedDeferredResult$).then(() => context); + resolveSpy.mockResolvedValueOnce(deferredResult); + const promise1 = mocks.profilesManagerMock.resolveRootProfile({ + solutionNavId: 'solutionNavId', + }); + expect(resolveSpy).toHaveReturnedTimes(1); + expect(resolveSpy).toHaveLastReturnedWith(deferredResult); + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + const resolvedDeferredResult2$ = new Subject(); + const deferredResult2 = firstValueFrom(resolvedDeferredResult2$).then(() => newContext); + resolveSpy.mockResolvedValueOnce(deferredResult2); + const promise2 = mocks.profilesManagerMock.resolveRootProfile({ + solutionNavId: 'newSolutionNavId', + }); + expect(resolveSpy).toHaveReturnedTimes(2); + expect(resolveSpy).toHaveLastReturnedWith(deferredResult2); + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + resolvedDeferredResult$.next(undefined); + await promise1; + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + resolvedDeferredResult2$.next(undefined); + await promise2; + expect(mocks.profilesManagerMock.getProfiles()).toEqual([ + mocks.rootProfileProviderMock.profile, + {}, + {}, + ]); + }); + + it('should cancel existing data source profile resolution when another is triggered', async () => { + const context = await mocks.dataSourceProfileProviderMock.resolve({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + const newContext = await mocks.dataSourceProfileProviderMock.resolve({ + dataSource: createEsqlDataSource(), + query: { esql: 'from logs-*' }, + }); + const resolveSpy = jest.spyOn(mocks.dataSourceProfileProviderMock, 'resolve'); + resolveSpy.mockClear(); + const resolvedDeferredResult$ = new Subject(); + const deferredResult = firstValueFrom(resolvedDeferredResult$).then(() => context); + resolveSpy.mockResolvedValueOnce(deferredResult); + const promise1 = mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from *' }, + }); + expect(resolveSpy).toHaveReturnedTimes(1); + expect(resolveSpy).toHaveLastReturnedWith(deferredResult); + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + const resolvedDeferredResult2$ = new Subject(); + const deferredResult2 = firstValueFrom(resolvedDeferredResult2$).then(() => newContext); + resolveSpy.mockResolvedValueOnce(deferredResult2); + const promise2 = mocks.profilesManagerMock.resolveDataSourceProfile({ + dataSource: createEsqlDataSource(), + query: { esql: 'from logs-*' }, + }); + expect(resolveSpy).toHaveReturnedTimes(2); + expect(resolveSpy).toHaveLastReturnedWith(deferredResult2); + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + resolvedDeferredResult$.next(undefined); + await promise1; + expect(mocks.profilesManagerMock.getProfiles()).toEqual([{}, {}, {}]); + resolvedDeferredResult2$.next(undefined); + await promise2; + expect(mocks.profilesManagerMock.getProfiles()).toEqual([ + {}, + mocks.dataSourceProfileProviderMock.profile, + {}, + ]); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/profiles_manager.ts b/src/plugins/discover/public/context_awareness/profiles_manager.ts new file mode 100644 index 0000000000000..316419d2a7d3f --- /dev/null +++ b/src/plugins/discover/public/context_awareness/profiles_manager.ts @@ -0,0 +1,206 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataTableRecord } from '@kbn/discover-utils'; +import { isOfAggregateQueryType } from '@kbn/es-query'; +import { isEqual } from 'lodash'; +import { BehaviorSubject, combineLatest, map } from 'rxjs'; +import { DataSourceType, isDataSourceType } from '../../common/data_sources'; +import { addLog } from '../utils/add_log'; +import type { + RootProfileService, + DataSourceProfileService, + DocumentProfileService, + RootProfileProviderParams, + DataSourceProfileProviderParams, + DocumentProfileProviderParams, + RootContext, + DataSourceContext, + DocumentContext, +} from './profiles'; +import type { ContextWithProfileId } from './profile_service'; + +interface SerializedRootProfileParams { + solutionNavId: RootProfileProviderParams['solutionNavId']; +} + +interface SerializedDataSourceProfileParams { + dataViewId: string | undefined; + esqlQuery: string | undefined; +} + +interface DataTableRecordWithContext extends DataTableRecord { + context: ContextWithProfileId; +} + +export interface GetProfilesOptions { + record?: DataTableRecord; +} + +export class ProfilesManager { + private readonly rootContext$: BehaviorSubject>; + private readonly dataSourceContext$: BehaviorSubject>; + + private prevRootProfileParams?: SerializedRootProfileParams; + private prevDataSourceProfileParams?: SerializedDataSourceProfileParams; + private rootProfileAbortController?: AbortController; + private dataSourceProfileAbortController?: AbortController; + + constructor( + private readonly rootProfileService: RootProfileService, + private readonly dataSourceProfileService: DataSourceProfileService, + private readonly documentProfileService: DocumentProfileService + ) { + this.rootContext$ = new BehaviorSubject(rootProfileService.defaultContext); + this.dataSourceContext$ = new BehaviorSubject(dataSourceProfileService.defaultContext); + } + + public async resolveRootProfile(params: RootProfileProviderParams) { + const serializedParams = serializeRootProfileParams(params); + + if (isEqual(this.prevRootProfileParams, serializedParams)) { + return; + } + + const abortController = new AbortController(); + this.rootProfileAbortController?.abort(); + this.rootProfileAbortController = abortController; + + let context = this.rootProfileService.defaultContext; + + try { + context = await this.rootProfileService.resolve(params); + } catch (e) { + logResolutionError(ContextType.Root, serializedParams, e); + } + + if (abortController.signal.aborted) { + return; + } + + this.rootContext$.next(context); + this.prevRootProfileParams = serializedParams; + } + + public async resolveDataSourceProfile(params: DataSourceProfileProviderParams) { + const serializedParams = serializeDataSourceProfileParams(params); + + if (isEqual(this.prevDataSourceProfileParams, serializedParams)) { + return; + } + + const abortController = new AbortController(); + this.dataSourceProfileAbortController?.abort(); + this.dataSourceProfileAbortController = abortController; + + let context = this.dataSourceProfileService.defaultContext; + + try { + context = await this.dataSourceProfileService.resolve(params); + } catch (e) { + logResolutionError(ContextType.DataSource, serializedParams, e); + } + + if (abortController.signal.aborted) { + return; + } + + this.dataSourceContext$.next(context); + this.prevDataSourceProfileParams = serializedParams; + } + + public resolveDocumentProfile(params: DocumentProfileProviderParams) { + let context: ContextWithProfileId | undefined; + + return new Proxy(params.record, { + has: (target, prop) => prop === 'context' || Reflect.has(target, prop), + get: (target, prop, receiver) => { + if (prop !== 'context') { + return Reflect.get(target, prop, receiver); + } + + if (!context) { + try { + context = this.documentProfileService.resolve(params); + } catch (e) { + logResolutionError(ContextType.Document, { recordId: params.record.id }, e); + context = this.documentProfileService.defaultContext; + } + } + + return context; + }, + }); + } + + public getProfiles({ record }: GetProfilesOptions = {}) { + return [ + this.rootProfileService.getProfile(this.rootContext$.getValue()), + this.dataSourceProfileService.getProfile(this.dataSourceContext$.getValue()), + this.documentProfileService.getProfile( + recordHasContext(record) ? record.context : this.documentProfileService.defaultContext + ), + ]; + } + + public getProfiles$(options: GetProfilesOptions = {}) { + return combineLatest([this.rootContext$, this.dataSourceContext$]).pipe( + map(() => this.getProfiles(options)) + ); + } +} + +const serializeRootProfileParams = ( + params: RootProfileProviderParams +): SerializedRootProfileParams => { + return { + solutionNavId: params.solutionNavId, + }; +}; + +const serializeDataSourceProfileParams = ( + params: DataSourceProfileProviderParams +): SerializedDataSourceProfileParams => { + return { + dataViewId: isDataSourceType(params.dataSource, DataSourceType.DataView) + ? params.dataSource.dataViewId + : undefined, + esqlQuery: + isDataSourceType(params.dataSource, DataSourceType.Esql) && + isOfAggregateQueryType(params.query) + ? params.query.esql + : undefined, + }; +}; + +const recordHasContext = ( + record: DataTableRecord | undefined +): record is DataTableRecordWithContext => { + return Boolean(record && 'context' in record); +}; + +enum ContextType { + Root = 'root', + DataSource = 'data source', + Document = 'document', +} + +const logResolutionError = ( + profileType: ContextType, + params: TParams, + error: TError +) => { + addLog( + `[ProfilesManager] ${profileType} context resolution failed with params: ${JSON.stringify( + params, + null, + 2 + )}`, + error + ); +}; diff --git a/src/plugins/discover/public/context_awareness/types.ts b/src/plugins/discover/public/context_awareness/types.ts new file mode 100644 index 0000000000000..b612b2ce29907 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/types.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { CustomCellRenderer } from '@kbn/unified-data-table'; + +export interface Profile { + getCellRenderers: () => CustomCellRenderer; +} diff --git a/src/plugins/discover/public/customizations/__mocks__/customization_context.ts b/src/plugins/discover/public/customizations/__mocks__/customization_context.ts index 6ede54673cda9..1fabe661dd20e 100644 --- a/src/plugins/discover/public/customizations/__mocks__/customization_context.ts +++ b/src/plugins/discover/public/customizations/__mocks__/customization_context.ts @@ -6,12 +6,6 @@ * Side Public License, v 1. */ -import type { DiscoverCustomizationContext } from '../types'; +import { defaultCustomizationContext } from '../defaults'; -export const mockCustomizationContext: DiscoverCustomizationContext = { - displayMode: 'standalone', - inlineTopNav: { - enabled: false, - showLogsExplorerTabs: false, - }, -}; +export const mockCustomizationContext = defaultCustomizationContext; diff --git a/src/plugins/discover/public/customizations/defaults.ts b/src/plugins/discover/public/customizations/defaults.ts index a9dc60ac356ff..034e7be2b5dc6 100644 --- a/src/plugins/discover/public/customizations/defaults.ts +++ b/src/plugins/discover/public/customizations/defaults.ts @@ -9,6 +9,7 @@ import { DiscoverCustomizationContext } from './types'; export const defaultCustomizationContext: DiscoverCustomizationContext = { + solutionNavId: null, displayMode: 'standalone', inlineTopNav: { enabled: false, diff --git a/src/plugins/discover/public/customizations/types.ts b/src/plugins/discover/public/customizations/types.ts index 079cde37da716..21419da709946 100644 --- a/src/plugins/discover/public/customizations/types.ts +++ b/src/plugins/discover/public/customizations/types.ts @@ -21,6 +21,10 @@ export type CustomizationCallback = ( export type DiscoverDisplayMode = 'embedded' | 'standalone'; export interface DiscoverCustomizationContext { + /** + * The current solution nav ID + */ + solutionNavId: string | null; /* * Display mode in which discover is running */ diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts index 35e336df52325..53ce8c798f251 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts @@ -9,6 +9,7 @@ import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; import type { DataView } from '@kbn/data-views-plugin/common'; +import { createDataViewDataSource } from '../../common/data_sources'; import { SHOW_FIELD_STATISTICS } from '@kbn/discover-utils'; import { buildDataViewMock, deepMockedFields } from '@kbn/discover-utils/src/__mocks__'; import { ViewMode } from '@kbn/embeddable-plugin/public'; @@ -17,7 +18,7 @@ import { ReactWrapper } from 'enzyme'; import { ReactElement } from 'react'; import { render } from 'react-dom'; import { act } from 'react-dom/test-utils'; -import { Observable, throwError } from 'rxjs'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { SearchInput } from '..'; import { VIEW_MODE } from '../../common/constants'; import { DiscoverServices } from '../build_services'; @@ -26,6 +27,7 @@ import { discoverServiceMock } from '../__mocks__/services'; import { getDiscoverLocatorParams } from './get_discover_locator_params'; import { SavedSearchEmbeddable, SearchEmbeddableConfig } from './saved_search_embeddable'; import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component'; +import { DiscoverGrid } from '../components/discover_grid'; jest.mock('./get_discover_locator_params', () => { const actual = jest.requireActual('./get_discover_locator_params'); @@ -140,6 +142,7 @@ describe('saved search embeddable', () => { }; beforeEach(() => { + jest.clearAllMocks(); mountpoint = document.createElement('div'); showFieldStatisticsMockValue = false; @@ -152,6 +155,10 @@ describe('saved search embeddable', () => { if (key === SHOW_FIELD_STATISTICS) return showFieldStatisticsMockValue; } ); + + jest + .spyOn(servicesMock.core.chrome, 'getActiveSolutionNavId$') + .mockReturnValue(new BehaviorSubject('test')); }); afterEach(() => { @@ -475,4 +482,56 @@ describe('saved search embeddable', () => { expect(editUrl).toBe('/base/mock-url'); }); }); + + describe('context awareness', () => { + it('should resolve root profile on init', async () => { + const resolveRootProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveRootProfile' + ); + const { embeddable } = createEmbeddable(); + expect(resolveRootProfileSpy).not.toHaveBeenCalled(); + await waitOneTick(); + expect(resolveRootProfileSpy).toHaveBeenCalledWith({ solutionNavId: 'test' }); + resolveRootProfileSpy.mockReset(); + expect(resolveRootProfileSpy).not.toHaveBeenCalled(); + embeddable.reload(); + await waitOneTick(); + expect(resolveRootProfileSpy).not.toHaveBeenCalled(); + }); + + it('should resolve data source profile when fetching', async () => { + const resolveDataSourceProfileSpy = jest.spyOn( + discoverServiceMock.profilesManager, + 'resolveDataSourceProfile' + ); + const { embeddable } = createEmbeddable(); + expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled(); + await waitOneTick(); + expect(resolveDataSourceProfileSpy).toHaveBeenCalledWith({ + dataSource: createDataViewDataSource({ dataViewId: dataViewMock.id! }), + dataView: dataViewMock, + query: embeddable.getInput().query, + }); + resolveDataSourceProfileSpy.mockReset(); + expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled(); + embeddable.reload(); + expect(resolveDataSourceProfileSpy).toHaveBeenCalledWith({ + dataSource: createDataViewDataSource({ dataViewId: dataViewMock.id! }), + dataView: dataViewMock, + query: embeddable.getInput().query, + }); + }); + + it('should pass cell renderers from profile', async () => { + const { embeddable } = createEmbeddable(); + await waitOneTick(); + embeddable.render(mountpoint); + const discoverGridComponent = discoverComponent.find(DiscoverGrid); + expect(discoverGridComponent.exists()).toBeTruthy(); + expect(Object.keys(discoverGridComponent.prop('externalCustomRenderers')!)).toEqual([ + 'rootProfile', + ]); + }); + }); }); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index 3a6f9f9c9c8ac..861a0d50eeba6 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { lastValueFrom, Subscription } from 'rxjs'; +import { firstValueFrom, lastValueFrom, Subscription } from 'rxjs'; import { onlyDisabledFiltersChanged, Filter, @@ -71,6 +71,7 @@ import { fetchEsql } from '../application/main/data_fetching/fetch_esql'; import { getValidViewMode } from '../application/main/utils/get_valid_view_mode'; import { ADHOC_DATA_VIEW_RENDER_EVENT } from '../constants'; import { getDiscoverLocatorParams } from './get_discover_locator_params'; +import { createDataViewDataSource, createEsqlDataSource } from '../../common/data_sources'; export interface SearchEmbeddableConfig { editable: boolean; @@ -163,6 +164,12 @@ export class SavedSearchEmbeddable await this.initializeOutput(); + const solutionNavId = await firstValueFrom( + this.services.core.chrome.getActiveSolutionNavId$() + ); + + await this.services.profilesManager.resolveRootProfile({ solutionNavId }); + // deferred loading of this embeddable is complete this.setInitializationFinished(); @@ -305,18 +312,29 @@ export class SavedSearchEmbeddable const isEsqlMode = this.isEsqlMode(savedSearch); try { + await this.services.profilesManager.resolveDataSourceProfile({ + dataSource: isOfAggregateQueryType(query) + ? createEsqlDataSource() + : dataView.id + ? createDataViewDataSource({ dataViewId: dataView.id }) + : undefined, + dataView, + query, + }); + // Request ES|QL data if (isEsqlMode && query) { - const result = await fetchEsql( - savedSearch.searchSource.getField('query')!, + const result = await fetchEsql({ + query: savedSearch.searchSource.getField('query')!, + inputQuery: this.input.query, + filters: this.input.filters, dataView, - this.services.data, - this.services.expressions, - this.services.inspector, - this.abortController.signal, - this.input.filters, - this.input.query - ); + abortSignal: this.abortController.signal, + inspectorAdapters: this.services.inspector, + data: this.services.data, + expressions: this.services.expressions, + profilesManager: this.services.profilesManager, + }); this.updateOutput({ ...this.getOutput(), diff --git a/src/plugins/discover/public/embeddable/saved_search_grid.tsx b/src/plugins/discover/public/embeddable/saved_search_grid.tsx index 8bf43fa5b3e3b..39a6dc1307c04 100644 --- a/src/plugins/discover/public/embeddable/saved_search_grid.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_grid.tsx @@ -21,6 +21,7 @@ import './saved_search_grid.scss'; import { DiscoverGridFlyout } from '../components/discover_grid_flyout'; import { SavedSearchEmbeddableBase } from './saved_search_embeddable_base'; import { TotalDocuments } from '../application/main/components/total_documents/total_documents'; +import { useProfileAccessor } from '../context_awareness'; export interface DiscoverGridEmbeddableProps extends Omit { @@ -88,6 +89,12 @@ export function DiscoverGridEmbeddable(props: DiscoverGridEmbeddableProps) { [props.totalHitCount] ); + const getCellRenderersAccessor = useProfileAccessor('getCellRenderers'); + const cellRenderers = useMemo(() => { + const getCellRenderers = getCellRenderersAccessor(() => ({})); + return getCellRenderers(); + }, [getCellRenderersAccessor]); + return ( { it('is compatible when embeddable is of type saved search, in view mode && appropriate permissions are set', async () => { const action = new ViewSavedSearchAction(applicationMock, services.locator); diff --git a/src/plugins/discover/public/hooks/show_confirm_panel.tsx b/src/plugins/discover/public/hooks/show_confirm_panel.tsx deleted file mode 100644 index 79d2524c93161..0000000000000 --- a/src/plugins/discover/public/hooks/show_confirm_panel.tsx +++ /dev/null @@ -1,71 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import { EuiConfirmModal } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; -import { StartRenderServices } from '../plugin'; - -let isOpenConfirmPanel = false; - -export const showConfirmPanel = ({ - onConfirm, - onCancel, - startServices, -}: { - onConfirm: () => void; - onCancel: () => void; - startServices: StartRenderServices; -}) => { - if (isOpenConfirmPanel) { - return; - } - - isOpenConfirmPanel = true; - const container = document.createElement('div'); - const onClose = () => { - ReactDOM.unmountComponentAtNode(container); - document.body.removeChild(container); - isOpenConfirmPanel = false; - }; - - document.body.appendChild(container); - const element = ( - - { - onClose(); - onCancel(); - }} - onConfirm={() => { - onClose(); - onConfirm(); - }} - cancelButtonText={i18n.translate('discover.confirmDataViewSave.cancel', { - defaultMessage: 'Cancel', - })} - confirmButtonText={i18n.translate('discover.confirmDataViewSave.saveAndContinue', { - defaultMessage: 'Save and continue', - })} - defaultFocusedButton="confirm" - > -

- {i18n.translate('discover.confirmDataViewSave.message', { - defaultMessage: 'The action you chose requires a saved data view.', - })} -

-
-
- ); - ReactDOM.render(element, container); -}; diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 2eb34b20345e4..7228070fe2d2c 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -82,6 +82,12 @@ import { import { getESQLSearchProvider } from './global_search/search_provider'; import { HistoryService } from './history_service'; import { ConfigSchema, ExperimentalFeatures } from '../common/config'; +import { + DataSourceProfileService, + DocumentProfileService, + ProfilesManager, + RootProfileService, +} from './context_awareness'; /** * @public @@ -209,8 +215,6 @@ export interface DiscoverStartPlugins { observabilityAIAssistant?: ObservabilityAIAssistantPublicStart; } -export type StartRenderServices = Pick; - /** * Contains Discover, one of the oldest parts of Kibana * Discover provides embeddables for Dashboards @@ -218,25 +222,28 @@ export type StartRenderServices = Pick { - constructor(private readonly initializerContext: PluginInitializerContext) { - this.experimentalFeatures = - initializerContext.config.get().experimental ?? this.experimentalFeatures; - } + private readonly rootProfileService = new RootProfileService(); + private readonly dataSourceProfileService = new DataSourceProfileService(); + private readonly documentProfileService = new DocumentProfileService(); + private readonly appStateUpdater = new BehaviorSubject(() => ({})); + private readonly historyService = new HistoryService(); + private readonly inlineTopNav: Map = + new Map([[null, defaultCustomizationContext.inlineTopNav]]); + private readonly experimentalFeatures: ExperimentalFeatures = { + ruleFormV2Enabled: false, + }; - private appStateUpdater = new BehaviorSubject(() => ({})); - private historyService = new HistoryService(); private scopedHistory?: ScopedHistory; private urlTracker?: UrlTracker; - private stopUrlTracking: (() => void) | undefined = undefined; + private stopUrlTracking?: () => void; private locator?: DiscoverAppLocator; private contextLocator?: DiscoverContextAppLocator; private singleDocLocator?: DiscoverSingleDocLocator; - private inlineTopNav: Map = new Map([ - [null, defaultCustomizationContext.inlineTopNav], - ]); - private experimentalFeatures: ExperimentalFeatures = { - ruleFormV2Enabled: false, - }; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.experimentalFeatures = + initializerContext.config.get().experimental ?? this.experimentalFeatures; + } setup( core: CoreSetup, @@ -331,6 +338,7 @@ export class DiscoverPlugin history: this.historyService.getHistory(), scopedHistory: this.scopedHistory, urlTracker: this.urlTracker!, + profilesManager: this.createProfilesManager(), setHeaderActionMenu: params.setHeaderActionMenu, }); @@ -344,10 +352,11 @@ export class DiscoverPlugin const customizationContext$: Observable = services.chrome .getActiveSolutionNavId$() .pipe( - map((navId) => ({ + map((solutionNavId) => ({ ...defaultCustomizationContext, + solutionNavId, inlineTopNav: - this.inlineTopNav.get(navId) ?? + this.inlineTopNav.get(solutionNavId) ?? this.inlineTopNav.get(null) ?? defaultCustomizationContext.inlineTopNav, })) @@ -412,10 +421,7 @@ export class DiscoverPlugin } start(core: CoreStart, plugins: DiscoverStartPlugins): DiscoverStart { - // we need to register the application service at setup, but to render it - // there are some start dependencies necessary, for this reason - // initializeServices are assigned at start and used - // when the application/embeddable is mounted + this.registerProfiles(); const viewSavedSearchAction = new ViewSavedSearchAction(core.application, this.locator!); @@ -423,7 +429,6 @@ export class DiscoverPlugin plugins.uiActions.registerTrigger(SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER); injectTruncateStyles(core.uiSettings.get(TRUNCATE_MAX_HEIGHT)); - const getDiscoverServicesInternal = () => this.getDiscoverServices(core, plugins); const isEsqlEnabled = core.uiSettings.get(ENABLE_ESQL); if (plugins.share && this.locator && isEsqlEnabled) { @@ -435,6 +440,10 @@ export class DiscoverPlugin ); } + const getDiscoverServicesInternal = () => { + return this.getDiscoverServices(core, plugins, this.createEmptyProfilesManager()); + }; + return { locator: this.locator, DiscoverContainer: (props: DiscoverContainerProps) => ( @@ -449,7 +458,34 @@ export class DiscoverPlugin } } - private getDiscoverServices = (core: CoreStart, plugins: DiscoverStartPlugins) => { + private registerProfiles() { + // TODO: Conditionally register example profiles for functional testing in a follow up PR + // this.rootProfileService.registerProvider(o11yRootProfileProvider); + // this.dataSourceProfileService.registerProvider(logsDataSourceProfileProvider); + // this.documentProfileService.registerProvider(logDocumentProfileProvider); + } + + private createProfilesManager() { + return new ProfilesManager( + this.rootProfileService, + this.dataSourceProfileService, + this.documentProfileService + ); + } + + private createEmptyProfilesManager() { + return new ProfilesManager( + new RootProfileService(), + new DataSourceProfileService(), + new DocumentProfileService() + ); + } + + private getDiscoverServices = ( + core: CoreStart, + plugins: DiscoverStartPlugins, + profilesManager = this.createProfilesManager() + ) => { return buildServices({ core, plugins, @@ -459,6 +495,7 @@ export class DiscoverPlugin singleDocLocator: this.singleDocLocator!, history: this.historyService.getHistory(), urlTracker: this.urlTracker!, + profilesManager, }); }; diff --git a/x-pack/plugins/observability_solution/logs_shared/public/components/log_ai_assistant/index.tsx b/x-pack/plugins/observability_solution/logs_shared/public/components/log_ai_assistant/index.tsx index 465f1f3f66eff..484af6e4a0809 100644 --- a/x-pack/plugins/observability_solution/logs_shared/public/components/log_ai_assistant/index.tsx +++ b/x-pack/plugins/observability_solution/logs_shared/public/components/log_ai_assistant/index.tsx @@ -22,12 +22,12 @@ export function createLogAIAssistant({ export const createLogsAIAssistantRenderer = (LogAIAssistantRender: ReturnType) => ({ doc }: ObservabilityLogsAIAssistantFeatureRenderDeps) => { - const mappedDoc = useMemo( + const mappedDoc = useMemo( () => ({ fields: Object.entries(doc.flattened).map(([field, value]) => ({ field, - value, - })) as LogAIAssistantDocument['fields'], + value: Array.isArray(value) ? value : [value], + })), }), [doc] ); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 09336d56965fa..f4067130e1346 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -2322,10 +2322,6 @@ "discover.backToTopLinkText": "Revenir en haut de la page.", "discover.badge.readOnly.text": "Lecture seule", "discover.badge.readOnly.tooltip": "Impossible d’enregistrer les recherches", - "discover.confirmDataViewSave.cancel": "Annuler", - "discover.confirmDataViewSave.message": "L'action que vous avez choisie requiert une vue de données enregistrée.", - "discover.confirmDataViewSave.saveAndContinue": "Enregistrer et continuer", - "discover.confirmDataViewSave.title": "Enregistrer la vue de données", "discover.context.breadcrumb": "Documents relatifs", "discover.context.failedToLoadAnchorDocumentDescription": "Échec de chargement du document ancré", "discover.context.failedToLoadAnchorDocumentErrorDescription": "Le document ancré n’a pas pu être chargé.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 55589d731e969..f18f09518aa37 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2319,10 +2319,6 @@ "discover.backToTopLinkText": "最上部へ戻る。", "discover.badge.readOnly.text": "読み取り専用", "discover.badge.readOnly.tooltip": "検索を保存できません", - "discover.confirmDataViewSave.cancel": "キャンセル", - "discover.confirmDataViewSave.message": "選択したアクションでは、保存されたデータビューが必要です。", - "discover.confirmDataViewSave.saveAndContinue": "保存して続行", - "discover.confirmDataViewSave.title": "データビューを保存", "discover.context.breadcrumb": "周りのドキュメント", "discover.context.failedToLoadAnchorDocumentDescription": "アンカードキュメントの読み込みに失敗しました", "discover.context.failedToLoadAnchorDocumentErrorDescription": "アンカードキュメントの読み込みに失敗しました。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 65cf2dad16d19..90699177a60b4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2323,10 +2323,6 @@ "discover.backToTopLinkText": "返回顶部。", "discover.badge.readOnly.text": "只读", "discover.badge.readOnly.tooltip": "无法保存搜索", - "discover.confirmDataViewSave.cancel": "取消", - "discover.confirmDataViewSave.message": "您选择的操作需要已保存的数据视图。", - "discover.confirmDataViewSave.saveAndContinue": "保存并继续", - "discover.confirmDataViewSave.title": "保存数据视图。", "discover.context.breadcrumb": "周围文档", "discover.context.failedToLoadAnchorDocumentDescription": "无法加载定位点文档", "discover.context.failedToLoadAnchorDocumentErrorDescription": "无法加载定位点文档。",