diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableinput.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableinput.md index d1d97d50f5948a..f36f7b4ee77a4c 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableinput.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableinput.md @@ -19,5 +19,6 @@ export declare type EmbeddableInput = { timeRange?: TimeRange; query?: Query; filters?: Filter[]; + searchSessionId?: string; }; ``` diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index fa45e433050abf..e5947b73b305be 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -139,7 +139,7 @@ export class DashboardAppController { dashboardCapabilities, scopedHistory, embeddableCapabilities: { visualizeCapabilities, mapsCapabilities }, - data: { query: queryService }, + data: { query: queryService, search: searchService }, core: { notifications, overlays, @@ -412,8 +412,9 @@ export class DashboardAppController { >(DASHBOARD_CONTAINER_TYPE); if (dashboardFactory) { + const searchSessionId = searchService.session.start(); dashboardFactory - .create(getDashboardInput()) + .create({ ...getDashboardInput(), searchSessionId }) .then((container: DashboardContainer | ErrorEmbeddable | undefined) => { if (container && !isErrorEmbeddable(container)) { dashboardContainer = container; @@ -572,7 +573,7 @@ export class DashboardAppController { differences.filters = appStateDashboardInput.filters; } - Object.keys(_.omit(containerInput, ['filters'])).forEach((key) => { + Object.keys(_.omit(containerInput, ['filters', 'searchSessionId'])).forEach((key) => { const containerValue = (containerInput as { [key: string]: unknown })[key]; const appStateValue = ((appStateDashboardInput as unknown) as { [key: string]: unknown })[ key @@ -590,7 +591,8 @@ export class DashboardAppController { const refreshDashboardContainer = () => { const changes = getChangesFromAppStateForContainerState(); if (changes && dashboardContainer) { - dashboardContainer.updateInput(changes); + const searchSessionId = searchService.session.start(); + dashboardContainer.updateInput({ ...changes, searchSessionId }); } }; @@ -1109,12 +1111,6 @@ export class DashboardAppController { $scope.model.filters = filterManager.getFilters(); $scope.model.query = queryStringManager.getQuery(); dashboardStateManager.applyFilters($scope.model.query, $scope.model.filters); - if (dashboardContainer) { - dashboardContainer.updateInput({ - filters: $scope.model.filters, - query: $scope.model.query, - }); - } }, }); @@ -1159,6 +1155,7 @@ export class DashboardAppController { if (dashboardContainer) { dashboardContainer.destroy(); } + searchService.session.clear(); }); } } diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx index a7226082d3dce3..89aacf2a84029c 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx @@ -134,3 +134,25 @@ test('Container view mode change propagates to new children', async () => { expect(embeddable.getInput().viewMode).toBe(ViewMode.EDIT); }); + +test('searchSessionId propagates to children', async () => { + const searchSessionId1 = 'searchSessionId1'; + const container = new DashboardContainer( + getSampleDashboardInput({ searchSessionId: searchSessionId1 }), + options + ); + const embeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Bob', + }); + + expect(embeddable.getInput().searchSessionId).toBe(searchSessionId1); + + const searchSessionId2 = 'searchSessionId2'; + container.updateInput({ searchSessionId: searchSessionId2 }); + + expect(embeddable.getInput().searchSessionId).toBe(searchSessionId2); +}); diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 036880a1d088ba..757488185fe8e5 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -78,6 +78,7 @@ export interface InheritedChildInput extends IndexSignature { viewMode: ViewMode; hidePanelTitles?: boolean; id: string; + searchSessionId?: string; } export interface DashboardContainerOptions { @@ -228,7 +229,15 @@ export class DashboardContainer extends Container { // Create a new search source that inherits the original search source // but has the appropriate timeRange applied via a filter. @@ -143,6 +145,7 @@ const handleCourierRequest = async ({ defaultMessage: 'This request queries Elasticsearch to fetch the data for the visualization.', }), + searchSessionId, } ); request.stats(getRequestInspectorStats(requestSearchSource)); @@ -150,6 +153,7 @@ const handleCourierRequest = async ({ try { const response = await requestSearchSource.fetch({ abortSignal, + sessionId: searchSessionId, }); request.stats(getResponseInspectorStats(response, searchSource)).ok({ json: response }); @@ -248,7 +252,7 @@ export const esaggs = (): EsaggsExpressionFunctionDefinition => ({ multi: true, }, }, - async fn(input, args, { inspectorAdapters, abortSignal }) { + async fn(input, args, { inspectorAdapters, abortSignal, getSearchSessionId }) { const indexPatterns = getIndexPatterns(); const { filterManager } = getQueryService(); const searchService = getSearchService(); @@ -276,6 +280,7 @@ export const esaggs = (): EsaggsExpressionFunctionDefinition => ({ inspectorAdapters: inspectorAdapters as Adapters, filterManager, abortSignal: (abortSignal as unknown) as AbortSignal, + searchSessionId: getSearchSessionId(), }); const table: Datatable = { diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index af88cacfcf9923..170078076ec6f4 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -266,6 +266,8 @@ export class SearchEmbeddable } private fetch = async () => { + const searchSessionId = this.input.searchSessionId; + if (!this.searchScope) return; const { searchSource } = this.savedSearch; @@ -292,7 +294,11 @@ export class SearchEmbeddable const description = i18n.translate('discover.embeddable.inspectorRequestDescription', { defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', }); - const inspectorRequest = this.inspectorAdaptors.requests.start(title, { description }); + + const inspectorRequest = this.inspectorAdaptors.requests.start(title, { + description, + searchSessionId, + }); inspectorRequest.stats(getRequestInspectorStats(searchSource)); searchSource.getSearchRequestBody().then((body: Record) => { inspectorRequest.json(body); @@ -303,6 +309,7 @@ export class SearchEmbeddable // Make the request const resp = await searchSource.fetch({ abortSignal: this.abortController.signal, + sessionId: searchSessionId, }); this.updateOutput({ loading: false, error: undefined }); diff --git a/src/plugins/embeddable/common/types.ts b/src/plugins/embeddable/common/types.ts index 68b842c934de89..2737f2678ff32c 100644 --- a/src/plugins/embeddable/common/types.ts +++ b/src/plugins/embeddable/common/types.ts @@ -67,4 +67,9 @@ export type EmbeddableInput = { * Visualization filters used to narrow down results. */ filters?: Filter[]; + + /** + * Search session id to group searches + */ + searchSessionId?: string; }; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.test.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.test.tsx index 340d851f3eedfc..b020006c0c2bb0 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.test.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.test.tsx @@ -25,6 +25,7 @@ import { EmbeddableOutput, EmbeddableInput } from './i_embeddable'; import { ViewMode } from '../types'; import { ContactCardEmbeddable } from '../test_samples/embeddables/contact_card/contact_card_embeddable'; import { FilterableEmbeddable } from '../test_samples/embeddables/filterable_embeddable'; +import type { Filter } from '../../../../data/public'; class TestClass { constructor() {} @@ -79,6 +80,20 @@ test('Embeddable reload is called if lastReloadRequest input time changes', asyn expect(hello.reload).toBeCalledTimes(1); }); +test('Embeddable reload is called if lastReloadRequest input time changed and new input is used', async () => { + const hello = new FilterableEmbeddable({ id: '123', filters: [], lastReloadRequestTime: 0 }); + + const aFilter = ({} as unknown) as Filter; + hello.reload = jest.fn(() => { + // when reload is called embeddable already has new input + expect(hello.getInput().filters).toEqual([aFilter]); + }); + + hello.updateInput({ lastReloadRequestTime: 1, filters: [aFilter] }); + + expect(hello.reload).toBeCalledTimes(1); +}); + test('Embeddable reload is not called if lastReloadRequest input time does not change', async () => { const hello = new FilterableEmbeddable({ id: '123', filters: [], lastReloadRequestTime: 1 }); diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index 9267d600360cfc..c7afc157c14523 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -195,14 +195,15 @@ export abstract class Embeddable< private onResetInput(newInput: TEmbeddableInput) { if (!isEqual(this.input, newInput)) { - if (this.input.lastReloadRequestTime !== newInput.lastReloadRequestTime) { - this.reload(); - } + const oldLastReloadRequestTime = this.input.lastReloadRequestTime; this.input = newInput; this.input$.next(newInput); this.updateOutput({ title: getPanelTitle(this.input, this.output), } as Partial); + if (oldLastReloadRequestTime !== newInput.lastReloadRequestTime) { + this.reload(); + } } } diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 84dd97c8288fce..9939ba2a0f8a13 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -425,6 +425,7 @@ export type EmbeddableInput = { timeRange?: TimeRange; query?: Query; filters?: Filter[]; + searchSessionId?: string; }; // Warning: (ae-missing-release-tag) "EmbeddableInstanceConfiguration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/src/plugins/inspector/common/adapters/request/types.ts b/src/plugins/inspector/common/adapters/request/types.ts index 0a62df7c8d10c9..c08c2ef6f706fe 100644 --- a/src/plugins/inspector/common/adapters/request/types.ts +++ b/src/plugins/inspector/common/adapters/request/types.ts @@ -49,6 +49,7 @@ export interface Request extends RequestParams { export interface RequestParams { id?: string; description?: string; + searchSessionId?: string; } export interface RequestStatistics { diff --git a/src/plugins/inspector/public/views/requests/components/requests_view.tsx b/src/plugins/inspector/public/views/requests/components/requests_view.tsx index a433ea70dc35c3..13575de0c5064f 100644 --- a/src/plugins/inspector/public/views/requests/components/requests_view.tsx +++ b/src/plugins/inspector/public/views/requests/components/requests_view.tsx @@ -153,6 +153,21 @@ export class RequestsViewComponent extends Component )} + {this.state.request && this.state.request.searchSessionId && ( + +

+ +

+
+ )} + {this.state.request && } diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index a810b4b65528f7..c12a0f0759018d 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -374,6 +374,7 @@ export class VisualizeEmbeddable query: this.input.query, filters: this.input.filters, }, + searchSessionId: this.input.searchSessionId, uiState: this.vis.uiState, inspectorAdapters: this.inspectorAdapters, }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index 3e05d4ddfbc20d..9dc59eacd40d3e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -172,6 +172,7 @@ describe('embeddable', () => { timeRange, query, filters, + searchSessionId: 'searchSessionId', }); expect(expressionRenderer).toHaveBeenCalledTimes(2); @@ -182,7 +183,13 @@ describe('embeddable', () => { const query: Query = { language: 'kquery', query: '' }; const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }]; - const input = { savedObjectId: '123', timeRange, query, filters } as LensEmbeddableInput; + const input = { + savedObjectId: '123', + timeRange, + query, + filters, + searchSessionId: 'searchSessionId', + } as LensEmbeddableInput; const embeddable = new Embeddable( { @@ -214,6 +221,8 @@ describe('embeddable', () => { filters, }) ); + + expect(expressionRenderer.mock.calls[0][0].searchSessionId).toBe(input.searchSessionId); }); it('should merge external context with query and filters of the saved object', async () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index d245b7f2fcde4c..10c243a2721385 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -177,6 +177,7 @@ export class Embeddable ExpressionRenderer={this.expressionRenderer} expression={this.expression || null} searchContext={this.getMergedSearchContext()} + searchSessionId={this.input.searchSessionId} handleEvent={this.handleEvent} />, domNode diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index 4fb0630a305e79..13376e56e21443 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -19,6 +19,7 @@ export interface ExpressionWrapperProps { ExpressionRenderer: ReactExpressionRendererType; expression: string | null; searchContext: ExecutionContextSearch; + searchSessionId?: string; handleEvent: (event: ExpressionRendererEvent) => void; } @@ -27,6 +28,7 @@ export function ExpressionWrapper({ expression, searchContext, handleEvent, + searchSessionId, }: ExpressionWrapperProps) { return ( @@ -51,6 +53,7 @@ export function ExpressionWrapper({ padding="m" expression={expression} searchContext={searchContext} + searchSessionId={searchSessionId} renderError={(errorMessage, error) => (
diff --git a/x-pack/test/functional/apps/dashboard/async_search/async_search.ts b/x-pack/test/functional/apps/dashboard/async_search/async_search.ts index 6932a88635a679..4d37ee15891691 100644 --- a/x-pack/test/functional/apps/dashboard/async_search/async_search.ts +++ b/x-pack/test/functional/apps/dashboard/async_search/async_search.ts @@ -12,6 +12,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const log = getService('log'); const PageObjects = getPageObjects(['common', 'header', 'dashboard', 'visChart']); + const dashboardPanelActions = getService('dashboardPanelActions'); + const inspector = getService('inspector'); + const queryBar = getService('queryBar'); describe('dashboard with async search', () => { before(async function () { @@ -24,7 +27,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('not delayed should load', async () => { await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.gotoDashboardEditMode('Not Delayed'); + await PageObjects.dashboard.loadSavedDashboard('Not Delayed'); await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.missingOrFail('embeddableErrorLabel'); const data = await PageObjects.visChart.getBarChartData('Sum of bytes'); @@ -33,7 +36,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('delayed should load', async () => { await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.gotoDashboardEditMode('Delayed 5s'); + await PageObjects.dashboard.loadSavedDashboard('Delayed 5s'); await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.missingOrFail('embeddableErrorLabel'); const data = await PageObjects.visChart.getBarChartData(''); @@ -42,10 +45,47 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('timed out should show error', async () => { await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.gotoDashboardEditMode('Delayed 15s'); + await PageObjects.dashboard.loadSavedDashboard('Delayed 15s'); await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('embeddableErrorLabel'); await testSubjects.existOrFail('searchTimeoutError'); }); + + it('multiple searches are grouped and only single error popup is shown', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard('Multiple delayed'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('embeddableErrorLabel'); + // there should be two failed panels + expect((await testSubjects.findAll('embeddableErrorLabel')).length).to.be(2); + // but only single error toast because searches are grouped + expect((await testSubjects.findAll('searchTimeoutError')).length).to.be(1); + + // check that session ids are the same + const getSearchSessionIdByPanel = async (panelTitle: string) => { + await dashboardPanelActions.openInspectorByTitle(panelTitle); + await inspector.openInspectorRequestsView(); + const searchSessionId = await ( + await testSubjects.find('inspectorRequestSearchSessionId') + ).getAttribute('data-search-session-id'); + await inspector.close(); + return searchSessionId; + }; + + const panel1SessionId1 = await getSearchSessionIdByPanel('Sum of Bytes by Extension'); + const panel2SessionId1 = await getSearchSessionIdByPanel( + 'Sum of Bytes by Extension (Delayed 5s)' + ); + expect(panel1SessionId1).to.be(panel2SessionId1); + + await queryBar.clickQuerySubmitButton(); + + const panel1SessionId2 = await getSearchSessionIdByPanel('Sum of Bytes by Extension'); + const panel2SessionId2 = await getSearchSessionIdByPanel( + 'Sum of Bytes by Extension (Delayed 5s)' + ); + expect(panel1SessionId2).to.be(panel2SessionId2); + expect(panel1SessionId1).not.to.be(panel1SessionId2); + }); }); } diff --git a/x-pack/test/functional/es_archives/dashboard/async_search/data.json b/x-pack/test/functional/es_archives/dashboard/async_search/data.json index 2990097e88d00c..486c73f711a6bf 100644 --- a/x-pack/test/functional/es_archives/dashboard/async_search/data.json +++ b/x-pack/test/functional/es_archives/dashboard/async_search/data.json @@ -194,4 +194,52 @@ } } +{ + "type": "doc", + "value": { + "id": "dashboard:a41c6790-075d-11eb-be70-0bd5e8b57d03", + "index": ".kibana", + "source": { + "dashboard": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "optionsJSON": "{\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"ec585931-ce8e-43fd-aa94-a1a9612d24ba\"},\"panelIndex\":\"ec585931-ce8e-43fd-aa94-a1a9612d24ba\",\"embeddableConfig\":{},\"panelRefName\":\"panel_0\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"c7b18010-462b-4e55-a974-fdec2ae64b06\"},\"panelIndex\":\"c7b18010-462b-4e55-a974-fdec2ae64b06\",\"embeddableConfig\":{},\"panelRefName\":\"panel_1\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"e67704f7-20b7-4ade-8dee-972a9d187107\"},\"panelIndex\":\"e67704f7-20b7-4ade-8dee-972a9d187107\",\"embeddableConfig\":{},\"panelRefName\":\"panel_2\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":24,\"y\":15,\"w\":24,\"h\":15,\"i\":\"f0b03592-10f1-41cd-9929-0cb4163bcd16\"},\"panelIndex\":\"f0b03592-10f1-41cd-9929-0cb4163bcd16\",\"embeddableConfig\":{},\"panelRefName\":\"panel_3\"}]", + "refreshInterval": { "pause": true, "value": 0 }, + "timeFrom": "2015-09-19T17:34:10.297Z", + "timeRestore": true, + "timeTo": "2015-09-23T00:09:17.180Z", + "title": "Multiple delayed", + "version": 1 + }, + "references": [ + { + "id": "14501a50-01e3-11eb-9b63-176d7b28a352", + "name": "panel_0", + "type": "visualization" + }, + { + "id": "50a67010-075d-11eb-be70-0bd5e8b57d02", + "name": "panel_1", + "type": "visualization" + }, + { + "id": "6c9f3830-01e3-11eb-9b63-176d7b28a352", + "name": "panel_2", + "type": "visualization" + }, + { + "id": "50a67010-075d-11eb-be70-0bd5e8b57d02", + "name": "panel_3", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2020-03-19T11:59:53.701Z" + } + } +}