From c7c6eba0799819d41df4b360581884b9c3c0ff8a Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Tue, 24 Mar 2020 15:41:31 +0000 Subject: [PATCH] Add async search notification (#60706) * notifications ui * increase timeout to 10s * trigger notification from search interceptor * added an enhanced interceptor * added an enhanced interceptor * docs * docs * fix ts * Fix jest tests for interceptor * update docs * docs * Fix handling syntax error in discover * docs and translations * fix scripted fields err Co-authored-by: Lukas Olson --- .../kibana-plugin-plugins-data-public.md | 2 + ...ublic.requesttimeouterror._constructor_.md | 20 +++ ...plugins-data-public.requesttimeouterror.md | 20 +++ ...-public.searchinterceptor._constructor_.md | 22 +++ ...ublic.searchinterceptor.abortcontroller.md | 13 ++ ...ta-public.searchinterceptor.application.md | 11 ++ ...blic.searchinterceptor.getpendingcount_.md | 13 ++ ...data-public.searchinterceptor.hidetoast.md | 11 ++ ...blic.searchinterceptor.longrunningtoast.md | 13 ++ ...n-plugins-data-public.searchinterceptor.md | 33 ++++ ...public.searchinterceptor.requesttimeout.md | 11 ++ ...ns-data-public.searchinterceptor.search.md | 13 ++ ...data-public.searchinterceptor.showtoast.md | 11 ++ ....searchinterceptor.timeoutsubscriptions.md | 13 ++ ...ns-data-public.searchinterceptor.toasts.md | 11 ++ src/plugins/data/public/index.ts | 2 + src/plugins/data/public/public.api.md | 63 ++++++-- src/plugins/data/public/search/index.ts | 1 + .../public/search/long_query_notification.tsx | 61 ++++++++ src/plugins/data/public/search/mocks.ts | 4 +- .../public/search/search_interceptor.test.ts | 57 ++----- .../data/public/search/search_interceptor.ts | 141 +++++++++++------- .../data/public/search/search_service.ts | 14 +- src/plugins/data/public/search/types.ts | 6 +- x-pack/plugins/data_enhanced/public/plugin.ts | 7 + .../public/search/long_query_notification.tsx | 47 ++++++ .../public/search/search_interceptor.test.ts | 67 +++++++++ .../public/search/search_interceptor.ts | 56 +++++++ 28 files changed, 620 insertions(+), 123 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md create mode 100644 src/plugins/data/public/search/long_query_notification.tsx create mode 100644 x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx create mode 100644 x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts create mode 100644 x-pack/plugins/data_enhanced/public/search/search_interceptor.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index ea77d6f39389b..6964c070097c5 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -18,7 +18,9 @@ | [IndexPatternSelect](./kibana-plugin-plugins-data-public.indexpatternselect.md) | | | [OptionedParamType](./kibana-plugin-plugins-data-public.optionedparamtype.md) | | | [Plugin](./kibana-plugin-plugins-data-public.plugin.md) | | +| [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) | Class used to signify that a request timed out. Useful for applications to conditionally handle this type of error differently than other errors. | | [SearchError](./kibana-plugin-plugins-data-public.searcherror.md) | | +| [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) | | | [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) | | | [TimeHistory](./kibana-plugin-plugins-data-public.timehistory.md) | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md new file mode 100644 index 0000000000000..25e472817b46d --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) > [(constructor)](./kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md) + +## RequestTimeoutError.(constructor) + +Constructs a new instance of the `RequestTimeoutError` class + +Signature: + +```typescript +constructor(message?: string); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| message | string | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md new file mode 100644 index 0000000000000..84b2fc3fe0b17 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) + +## RequestTimeoutError class + +Class used to signify that a request timed out. Useful for applications to conditionally handle this type of error differently than other errors. + +Signature: + +```typescript +export declare class RequestTimeoutError extends Error +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(message)](./kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md) | | Constructs a new instance of the RequestTimeoutError class | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md new file mode 100644 index 0000000000000..6eabefb9eb912 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [(constructor)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) + +## SearchInterceptor.(constructor) + +This class should be instantiated with a `requestTimeout` corresponding with how many ms after requests are initiated that they should automatically cancel. + +Signature: + +```typescript +constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number | undefined); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| toasts | ToastsStart | | +| application | ApplicationStart | | +| requestTimeout | number | undefined | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md new file mode 100644 index 0000000000000..0451a2254dc40 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [abortController](./kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md) + +## SearchInterceptor.abortController property + +`abortController` used to signal all searches to abort. + +Signature: + +```typescript +protected abortController: AbortController; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md new file mode 100644 index 0000000000000..e44910161aa60 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [application](./kibana-plugin-plugins-data-public.searchinterceptor.application.md) + +## SearchInterceptor.application property + +Signature: + +```typescript +protected readonly application: ApplicationStart; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md new file mode 100644 index 0000000000000..59b107c92424f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getPendingCount$](./kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md) + +## SearchInterceptor.getPendingCount$ property + +Returns an `Observable` over the current number of pending searches. This could mean that one of the search requests is still in flight, or that it has only received partial responses. + +Signature: + +```typescript +getPendingCount$: () => import("rxjs").Observable; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md new file mode 100644 index 0000000000000..59938a755a99e --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [hideToast](./kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md) + +## SearchInterceptor.hideToast property + +Signature: + +```typescript +protected hideToast: () => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md new file mode 100644 index 0000000000000..5799039de91bc --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [longRunningToast](./kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md) + +## SearchInterceptor.longRunningToast property + +The current long-running toast (if there is one). + +Signature: + +```typescript +protected longRunningToast?: Toast; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md new file mode 100644 index 0000000000000..0c7b123be72af --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -0,0 +1,33 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) + +## SearchInterceptor class + +Signature: + +```typescript +export declare class SearchInterceptor +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(toasts, application, requestTimeout)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) | | This class should be instantiated with a requestTimeout corresponding with how many ms after requests are initiated that they should automatically cancel. | + +## Properties + +| Property | Modifiers | Type | Description | +| --- | --- | --- | --- | +| [abortController](./kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md) | | AbortController | abortController used to signal all searches to abort. | +| [application](./kibana-plugin-plugins-data-public.searchinterceptor.application.md) | | ApplicationStart | | +| [getPendingCount$](./kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md) | | () => import("rxjs").Observable<number> | Returns an Observable over the current number of pending searches. This could mean that one of the search requests is still in flight, or that it has only received partial responses. | +| [hideToast](./kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md) | | () => void | | +| [longRunningToast](./kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md) | | Toast | The current long-running toast (if there is one). | +| [requestTimeout](./kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md) | | number | undefined | | +| [search](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable<import("../../common/search").IEsSearchResponse<unknown>> | Searches using the given search method. Overrides the AbortSignal with one that will abort either when cancelPending is called, when the request times out, or when the original AbortSignal is aborted. Updates the pendingCount when the request is started/finalized. | +| [showToast](./kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md) | | () => void | | +| [timeoutSubscriptions](./kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md) | | Set<Subscription> | The subscriptions from scheduling the automatic timeout for each request. | +| [toasts](./kibana-plugin-plugins-data-public.searchinterceptor.toasts.md) | | ToastsStart | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md new file mode 100644 index 0000000000000..3123433762991 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [requestTimeout](./kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md) + +## SearchInterceptor.requestTimeout property + +Signature: + +```typescript +protected readonly requestTimeout?: number | undefined; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md new file mode 100644 index 0000000000000..80c98ab84fb40 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [search](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) + +## SearchInterceptor.search property + +Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when `cancelPending` is called, when the request times out, or when the original `AbortSignal` is aborted. Updates the `pendingCount` when the request is started/finalized. + +Signature: + +```typescript +search: (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable>; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md new file mode 100644 index 0000000000000..e495c72b57215 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [showToast](./kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md) + +## SearchInterceptor.showToast property + +Signature: + +```typescript +protected showToast: () => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md new file mode 100644 index 0000000000000..072f67591f097 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [timeoutSubscriptions](./kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md) + +## SearchInterceptor.timeoutSubscriptions property + +The subscriptions from scheduling the automatic timeout for each request. + +Signature: + +```typescript +protected timeoutSubscriptions: Set; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md new file mode 100644 index 0000000000000..4953d17c89c39 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [toasts](./kibana-plugin-plugins-data-public.searchinterceptor.toasts.md) + +## SearchInterceptor.toasts property + +Signature: + +```typescript +protected readonly toasts: ToastsStart; +``` diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 977b9568ceaa6..efafea44167d4 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -374,6 +374,8 @@ export { TabbedAggColumn, TabbedAggRow, TabbedTable, + SearchInterceptor, + RequestTimeoutError, } from './search'; // Search namespace diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index f0807187fc254..fcdbccfb42592 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -7,6 +7,7 @@ import { $Values } from '@kbn/utility-types'; import _ from 'lodash'; import { Action } from 'history'; +import { ApplicationStart } from 'kibana/public'; import { Assign } from '@kbn/utility-types'; import { Breadcrumb } from '@elastic/eui'; import { Component } from 'react'; @@ -48,6 +49,9 @@ import { SavedObjectsClientContract } from 'src/core/public'; import { SearchParams } from 'elasticsearch'; import { SearchResponse as SearchResponse_2 } from 'elasticsearch'; import { SimpleSavedObject } from 'src/core/public'; +import { Subscription } from 'rxjs'; +import { Toast } from 'kibana/public'; +import { ToastsStart } from 'kibana/public'; import { UiActionsSetup } from 'src/plugins/ui_actions/public'; import { UiActionsStart } from 'src/plugins/ui_actions/public'; import { Unit } from '@elastic/datemath'; @@ -1465,6 +1469,13 @@ export interface RefreshInterval { value: number; } +// Warning: (ae-missing-release-tag) "RequestTimeoutError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class RequestTimeoutError extends Error { + constructor(message?: string); +} + // Warning: (ae-missing-release-tag) "SavedQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1582,6 +1593,28 @@ export class SearchError extends Error { type: string; } +// Warning: (ae-missing-release-tag) "SearchInterceptor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class SearchInterceptor { + constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number | undefined); + protected abortController: AbortController; + // (undocumented) + protected readonly application: ApplicationStart; + getPendingCount$: () => import("rxjs").Observable; + // (undocumented) + protected hideToast: () => void; + protected longRunningToast?: Toast; + // (undocumented) + protected readonly requestTimeout?: number | undefined; + search: (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable>; + // (undocumented) + protected showToast: () => void; + protected timeoutSubscriptions: Set; + // (undocumented) + protected readonly toasts: ToastsStart; +} + // Warning: (ae-missing-release-tag) "SearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1848,21 +1881,21 @@ export type TSearchStrategyProvider = (context: ISearc // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:386:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:402:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:404:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromEvent" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index f3d2d99af5998..1687d749f46e2 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -57,5 +57,6 @@ export { } from './search_source'; export { SearchInterceptor } from './search_interceptor'; +export { RequestTimeoutError } from './request_timeout_error'; export { FetchOptions } from './fetch'; diff --git a/src/plugins/data/public/search/long_query_notification.tsx b/src/plugins/data/public/search/long_query_notification.tsx new file mode 100644 index 0000000000000..590fee20db690 --- /dev/null +++ b/src/plugins/data/public/search/long_query_notification.tsx @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { ApplicationStart } from 'kibana/public'; +import { toMountPoint } from '../../../kibana_react/public'; + +interface Props { + application: ApplicationStart; +} + +export function getLongQueryNotification(props: Props) { + return toMountPoint(); +} + +export function LongQueryNotification(props: Props) { + return ( +
+ + + + + { + await props.application.navigateToApp( + 'kibana#/management/elasticsearch/license_management' + ); + }} + > + + + + +
+ ); +} diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index 12cf258759a99..b70e889066a45 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -31,10 +31,8 @@ export const searchSetupMock = { export const searchStartMock: jest.Mocked = { aggs: searchAggsStartMock(), + setInterceptor: jest.fn(), search: jest.fn(), - cancel: jest.fn(), - getPendingCount$: jest.fn(), - runBeyondTimeout: jest.fn(), __LEGACY: { AggConfig: jest.fn() as any, AggType: jest.fn(), diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index a89d17464b9e0..bd056271688c1 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -18,27 +18,38 @@ */ import { Observable, Subject } from 'rxjs'; +import { CoreStart } from '../../../../core/public'; +import { coreMock } from '../../../../core/public/mocks'; import { IKibanaSearchRequest } from '../../common/search'; import { RequestTimeoutError } from './request_timeout_error'; import { SearchInterceptor } from './search_interceptor'; jest.useFakeTimers(); -const flushPromises = () => new Promise(resolve => setImmediate(resolve)); const mockSearch = jest.fn(); let searchInterceptor: SearchInterceptor; +let mockCoreStart: MockedKeys; describe('SearchInterceptor', () => { beforeEach(() => { + mockCoreStart = coreMock.createStart(); mockSearch.mockClear(); - searchInterceptor = new SearchInterceptor(1000); + searchInterceptor = new SearchInterceptor( + mockCoreStart.notifications.toasts, + mockCoreStart.application, + 1000 + ); }); describe('search', () => { test('should invoke `search` with the request', () => { - mockSearch.mockReturnValue(new Observable()); + const mockResponse = new Subject(); + mockSearch.mockReturnValue(mockResponse.asObservable()); const mockRequest: IKibanaSearchRequest = {}; - searchInterceptor.search(mockSearch, mockRequest); + const response = searchInterceptor.search(mockSearch, mockRequest); + mockResponse.complete(); + + response.subscribe(); expect(mockSearch.mock.calls[0][0]).toBe(mockRequest); }); @@ -92,44 +103,6 @@ describe('SearchInterceptor', () => { }); }); - describe('cancelPending', () => { - test('should abort all pending requests', async () => { - mockSearch.mockReturnValue(new Observable()); - - searchInterceptor.search(mockSearch, {}); - searchInterceptor.search(mockSearch, {}); - searchInterceptor.cancelPending(); - - await flushPromises(); - - const areAllRequestsAborted = mockSearch.mock.calls.every(([, { signal }]) => signal.aborted); - expect(areAllRequestsAborted).toBe(true); - }); - }); - - describe('runBeyondTimeout', () => { - test('should prevent the request from timing out', () => { - const mockResponse = new Subject(); - mockSearch.mockReturnValue(mockResponse.asObservable()); - const response = searchInterceptor.search(mockSearch, {}); - - setTimeout(searchInterceptor.runBeyondTimeout, 500); - setTimeout(() => mockResponse.next('hi'), 250); - setTimeout(() => mockResponse.complete(), 2000); - - const next = jest.fn(); - const complete = jest.fn(); - const error = jest.fn(); - response.subscribe({ next, error, complete }); - - jest.advanceTimersByTime(2000); - - expect(next).toHaveBeenCalledWith('hi'); - expect(error).not.toHaveBeenCalled(); - expect(complete).toHaveBeenCalled(); - }); - }); - describe('getPendingCount$', () => { test('should observe the number of pending requests', () => { let i = 0; diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index 3f83214f6050c..d83ddab807bc5 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -17,51 +17,59 @@ * under the License. */ -import { BehaviorSubject, fromEvent, throwError } from 'rxjs'; -import { mergeMap, takeUntil, finalize } from 'rxjs/operators'; +import { BehaviorSubject, throwError, timer, Subscription, defer, fromEvent } from 'rxjs'; +import { takeUntil, finalize, filter, mergeMapTo } from 'rxjs/operators'; +import { ApplicationStart, Toast, ToastsStart } from 'kibana/public'; import { getCombinedSignal } from '../../common/utils'; import { IKibanaSearchRequest } from '../../common/search'; import { ISearchGeneric, ISearchOptions } from './i_search'; import { RequestTimeoutError } from './request_timeout_error'; +import { getLongQueryNotification } from './long_query_notification'; export class SearchInterceptor { /** * `abortController` used to signal all searches to abort. */ - private abortController = new AbortController(); + protected abortController = new AbortController(); /** - * Observable that emits when the number of pending requests changes. + * The number of pending search requests. */ - private pendingCount$ = new BehaviorSubject(0); + private pendingCount = 0; /** - * The IDs from `setTimeout` when scheduling the automatic timeout for each request. + * Observable that emits when the number of pending requests changes. */ - private timeoutIds: Set = new Set(); + private pendingCount$ = new BehaviorSubject(this.pendingCount); /** - * This class should be instantiated with a `requestTimeout` corresponding with how many ms after - * requests are initiated that they should automatically cancel. - * @param requestTimeout Usually config value `elasticsearch.requestTimeout` + * The subscriptions from scheduling the automatic timeout for each request. */ - constructor(private readonly requestTimeout?: number) {} + protected timeoutSubscriptions: Set = new Set(); /** - * Abort our `AbortController`, which in turn aborts any intercepted searches. + * The current long-running toast (if there is one). */ - public cancelPending = () => { - this.abortController.abort(); - this.abortController = new AbortController(); - }; + protected longRunningToast?: Toast; /** - * Un-schedule timing out all of the searches intercepted. + * This class should be instantiated with a `requestTimeout` corresponding with how many ms after + * requests are initiated that they should automatically cancel. + * @param toasts The `core.notifications.toasts` service + * @param application The `core.application` service + * @param requestTimeout Usually config value `elasticsearch.requestTimeout` */ - public runBeyondTimeout = () => { - this.timeoutIds.forEach(clearTimeout); - this.timeoutIds.clear(); - }; + constructor( + protected readonly toasts: ToastsStart, + protected readonly application: ApplicationStart, + protected readonly requestTimeout?: number + ) { + // When search requests go out, a notification is scheduled allowing users to continue the + // request past the timeout. When all search requests complete, we remove the notification. + this.getPendingCount$() + .pipe(filter(count => count === 0)) + .subscribe(this.hideToast); + } /** * Returns an `Observable` over the current number of pending searches. This could mean that one @@ -81,41 +89,66 @@ export class SearchInterceptor { request: IKibanaSearchRequest, options?: ISearchOptions ) => { - // Schedule this request to automatically timeout after some interval - const timeoutController = new AbortController(); - const { signal: timeoutSignal } = timeoutController; - const timeoutId = window.setTimeout(() => { - timeoutController.abort(); - }, this.requestTimeout); - this.addTimeoutId(timeoutId); - - // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: - // 1. The user manually aborts (via `cancelPending`) - // 2. The request times out - // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines) - const signals = [this.abortController.signal, timeoutSignal, options?.signal].filter( - Boolean - ) as AbortSignal[]; - const combinedSignal = getCombinedSignal(signals); - - // If the request timed out, throw a `RequestTimeoutError` - const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe( - mergeMap(() => throwError(new RequestTimeoutError())) - ); + // Defer the following logic until `subscribe` is actually called + return defer(() => { + this.pendingCount$.next(++this.pendingCount); - return search(request as any, { ...options, signal: combinedSignal }).pipe( - takeUntil(timeoutError$), - finalize(() => this.removeTimeoutId(timeoutId)) - ); + // Schedule this request to automatically timeout after some interval + const timeoutController = new AbortController(); + const { signal: timeoutSignal } = timeoutController; + const timeout$ = timer(this.requestTimeout); + const subscription = timeout$.subscribe(() => timeoutController.abort()); + this.timeoutSubscriptions.add(subscription); + + // If the request timed out, throw a `RequestTimeoutError` + const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe( + mergeMapTo(throwError(new RequestTimeoutError())) + ); + + // Schedule the notification to allow users to cancel or wait beyond the timeout + const notificationSubscription = timer(10000).subscribe(this.showToast); + + // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: + // 1. The user manually aborts (via `cancelPending`) + // 2. The request times out + // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines) + const signals = [ + this.abortController.signal, + timeoutSignal, + ...(options?.signal ? [options.signal] : []), + ]; + const combinedSignal = getCombinedSignal(signals); + + return search(request as any, { ...options, signal: combinedSignal }).pipe( + takeUntil(timeoutError$), + finalize(() => { + this.pendingCount$.next(--this.pendingCount); + this.timeoutSubscriptions.delete(subscription); + notificationSubscription.unsubscribe(); + }) + ); + }); }; - private addTimeoutId(id: number) { - this.timeoutIds.add(id); - this.pendingCount$.next(this.timeoutIds.size); - } + protected showToast = () => { + if (this.longRunningToast) return; + this.longRunningToast = this.toasts.addInfo( + { + title: 'Your query is taking awhile', + text: getLongQueryNotification({ + application: this.application, + }), + }, + { + toastLifeTimeMs: Infinity, + } + ); + }; - private removeTimeoutId(id: number) { - this.timeoutIds.delete(id); - this.pendingCount$.next(this.timeoutIds.size); - } + protected hideToast = () => { + if (this.longRunningToast) { + this.toasts.remove(this.longRunningToast); + delete this.longRunningToast; + } + }; } diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 62c7e0468bb88..311a8a2fc6f60 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -58,6 +58,7 @@ export class SearchService implements Plugin { private esClient?: LegacyApiCaller; private readonly aggTypesRegistry = new AggTypesRegistry(); + private searchInterceptor!: SearchInterceptor; private registerSearchStrategyProvider = ( name: T, @@ -98,7 +99,9 @@ export class SearchService implements Plugin { * TODO: Make this modular so that apps can opt in/out of search collection, or even provide * their own search collector instances */ - const searchInterceptor = new SearchInterceptor( + this.searchInterceptor = new SearchInterceptor( + core.notifications.toasts, + core.application, core.injectedMetadata.getInjectedVar('esRequestTimeout') as number ); @@ -114,16 +117,17 @@ export class SearchService implements Plugin { }, types: aggTypesStart, }, - cancel: () => searchInterceptor.cancelPending(), - getPendingCount$: () => searchInterceptor.getPendingCount$(), - runBeyondTimeout: () => searchInterceptor.runBeyondTimeout(), search: (request, options, strategyName) => { const strategyProvider = this.getSearchStrategy(strategyName || DEFAULT_SEARCH_STRATEGY); const { search } = strategyProvider({ core, getSearchStrategy: this.getSearchStrategy, }); - return searchInterceptor.search(search as any, request, options); + return this.searchInterceptor.search(search as any, request, options); + }, + setInterceptor: (searchInterceptor: SearchInterceptor) => { + // TODO: should an intercepror have a destroy method? + this.searchInterceptor = searchInterceptor; }, __LEGACY: { esClient: this.esClient!, diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 1b551f978b971..03cbfa9f8ed84 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -17,12 +17,12 @@ * under the License. */ -import { Observable } from 'rxjs'; import { CoreStart } from 'kibana/public'; import { SearchAggsSetup, SearchAggsStart, SearchAggsStartLegacy } from './aggs'; import { ISearch, ISearchGeneric } from './i_search'; import { TStrategyTypes } from './strategy_types'; import { LegacyApiCaller } from './es_client'; +import { SearchInterceptor } from './search_interceptor'; export interface ISearchContext { core: CoreStart; @@ -87,9 +87,7 @@ export interface ISearchSetup { export interface ISearchStart { aggs: SearchAggsStart; - cancel: () => void; - getPendingCount$: () => Observable; - runBeyondTimeout: () => void; + setInterceptor: (searchInterceptor: SearchInterceptor) => void; search: ISearchGeneric; __LEGACY: ISearchStartLegacy & SearchAggsStartLegacy; } diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 6316d87c50519..72e0817eea8df 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -17,6 +17,7 @@ import { asyncSearchStrategyProvider, enhancedEsSearchStrategyProvider, } from './search'; +import { EnhancedSearchInterceptor } from './search/search_interceptor'; export interface DataEnhancedSetupDependencies { data: DataPublicPluginSetup; @@ -45,5 +46,11 @@ export class DataEnhancedPlugin implements Plugin { public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { setAutocompleteService(plugins.data.autocomplete); + const enhancedSearchInterceptor = new EnhancedSearchInterceptor( + core.notifications.toasts, + core.application, + core.injectedMetadata.getInjectedVar('esRequestTimeout') as number + ); + plugins.data.search.setInterceptor(enhancedSearchInterceptor); } } diff --git a/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx b/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx new file mode 100644 index 0000000000000..325cf1145fa5f --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; + +interface Props { + cancel: () => void; + runBeyondTimeout: () => void; +} + +export function getLongQueryNotification(props: Props) { + return toMountPoint( + + ); +} + +export function LongQueryNotification(props: Props) { + return ( +
+ + + + + + + + + + + + + +
+ ); +} diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts new file mode 100644 index 0000000000000..1e554d3ff2d86 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, Subject } from 'rxjs'; +import { coreMock } from '../../../../../src/core/public/mocks'; +import { EnhancedSearchInterceptor } from './search_interceptor'; +import { CoreStart } from 'kibana/public'; + +jest.useFakeTimers(); + +const flushPromises = () => new Promise(resolve => setImmediate(resolve)); +const mockSearch = jest.fn(); +let searchInterceptor: EnhancedSearchInterceptor; +let mockCoreStart: MockedKeys; + +describe('EnhancedSearchInterceptor', () => { + beforeEach(() => { + mockCoreStart = coreMock.createStart(); + mockSearch.mockClear(); + searchInterceptor = new EnhancedSearchInterceptor( + mockCoreStart.notifications.toasts, + mockCoreStart.application, + 1000 + ); + }); + + describe('cancelPending', () => { + test('should abort all pending requests', async () => { + mockSearch.mockReturnValue(new Observable()); + + searchInterceptor.search(mockSearch, {}); + searchInterceptor.search(mockSearch, {}); + searchInterceptor.cancelPending(); + + await flushPromises(); + + const areAllRequestsAborted = mockSearch.mock.calls.every(([, { signal }]) => signal.aborted); + expect(areAllRequestsAborted).toBe(true); + }); + }); + + describe('runBeyondTimeout', () => { + test('should prevent the request from timing out', () => { + const mockResponse = new Subject(); + mockSearch.mockReturnValue(mockResponse.asObservable()); + const response = searchInterceptor.search(mockSearch, {}); + + setTimeout(searchInterceptor.runBeyondTimeout, 500); + setTimeout(() => mockResponse.next('hi'), 250); + setTimeout(() => mockResponse.complete(), 2000); + + const next = jest.fn(); + const complete = jest.fn(); + const error = jest.fn(); + response.subscribe({ next, error, complete }); + + jest.advanceTimersByTime(2000); + + expect(next).toHaveBeenCalledWith('hi'); + expect(error).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts new file mode 100644 index 0000000000000..38452dee9a2da --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ApplicationStart, ToastsStart } from 'kibana/public'; +import { getLongQueryNotification } from './long_query_notification'; +import { SearchInterceptor } from '../../../../../src/plugins/data/public'; + +export class EnhancedSearchInterceptor extends SearchInterceptor { + /** + * This class should be instantiated with a `requestTimeout` corresponding with how many ms after + * requests are initiated that they should automatically cancel. + * @param toasts The `core.notifications.toasts` service + * @param application The `core.application` service + * @param requestTimeout Usually config value `elasticsearch.requestTimeout` + */ + constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number) { + super(toasts, application, requestTimeout); + } + + /** + * Abort our `AbortController`, which in turn aborts any intercepted searches. + */ + public cancelPending = () => { + this.hideToast(); + this.abortController.abort(); + this.abortController = new AbortController(); + }; + + /** + * Un-schedule timing out all of the searches intercepted. + */ + public runBeyondTimeout = () => { + this.hideToast(); + this.timeoutSubscriptions.forEach(subscription => subscription.unsubscribe()); + this.timeoutSubscriptions.clear(); + }; + + protected showToast = () => { + if (this.longRunningToast) return; + this.longRunningToast = this.toasts.addInfo( + { + title: 'Your query is taking awhile', + text: getLongQueryNotification({ + cancel: this.cancelPending, + runBeyondTimeout: this.runBeyondTimeout, + }), + }, + { + toastLifeTimeMs: Infinity, + } + ); + }; +}