* Add a session service and use it in discover and dashboard
* check unefined
* Start session in visualize
* Fix tests
* docs
* OSS error alignemnt
* Adjust error messages in xpack
* Add getErrorMessage
* Use showError in vizualize
Add original error to expression exception
* Cleanup
* ts, doc and i18n fixes
* Fix jest tests
* Fix functional test
* functional test
* ts
* Update functional tests
* Add unit tests to interceptor and timeout error
* expose toasts test function
* doc
* typos
* lint
* Cleanup
* review 1
* Code review
* doc
* doc fix
* visualization type fix
* fix jest
* Fix xpack functional test
* fix xpack test
* code review
* Add tracking methods to session service
* remove chromium
* Fix ts and jest tests
* jest + docs
* ts fix
* siem test
* Use session service to show a timeout notification per session + more unit tests
* ts and docs
* Remove session service from search source (not needed)
* Code review
* ts
* Single active session in FE session service
* Cleanup
* Don't integrate with dashboard \ visualize
Add functional tests for session toast plugin
* Typescript
* ts
* Improve functional tests
* es
* simplify filter test
* wait until loadedw
* filter test
* delete crypto for now
* Select the correct index 🤦
* timerange
* Adjust functional test logic
* improved test format @dosant
* Handle exceptions
* Don't close sessions automatically, warn instead
* jest
* Adjust functional test
* Remove unused code
* delete export
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
cf9c0e1192
commit
59e60118bc
|
@ -15,5 +15,6 @@ export interface ISearchOptions
|
|||
| Property | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| [abortSignal](./kibana-plugin-plugins-data-public.isearchoptions.abortsignal.md) | <code>AbortSignal</code> | An <code>AbortSignal</code> that allows the caller of <code>search</code> to abort a search request. |
|
||||
| [sessionId](./kibana-plugin-plugins-data-public.isearchoptions.sessionid.md) | <code>string</code> | A session ID, grouping multiple search requests into a single session. |
|
||||
| [strategy](./kibana-plugin-plugins-data-public.isearchoptions.strategy.md) | <code>string</code> | Use this option to force using a specific server side search strategy. Leave empty to use the default strategy. |
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ISearchOptions](./kibana-plugin-plugins-data-public.isearchoptions.md) > [sessionId](./kibana-plugin-plugins-data-public.isearchoptions.sessionid.md)
|
||||
|
||||
## ISearchOptions.sessionId property
|
||||
|
||||
A session ID, grouping multiple search requests into a single session.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
sessionId?: string;
|
||||
```
|
|
@ -17,5 +17,6 @@ export interface ISearchSetup
|
|||
| Property | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| [aggs](./kibana-plugin-plugins-data-public.isearchsetup.aggs.md) | <code>AggsSetup</code> | |
|
||||
| [session](./kibana-plugin-plugins-data-public.isearchsetup.session.md) | <code>ISessionService</code> | session management |
|
||||
| [usageCollector](./kibana-plugin-plugins-data-public.isearchsetup.usagecollector.md) | <code>SearchUsageCollector</code> | |
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ISearchSetup](./kibana-plugin-plugins-data-public.isearchsetup.md) > [session](./kibana-plugin-plugins-data-public.isearchsetup.session.md)
|
||||
|
||||
## ISearchSetup.session property
|
||||
|
||||
session management
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
session: ISessionService;
|
||||
```
|
|
@ -19,5 +19,6 @@ export interface ISearchStart
|
|||
| [aggs](./kibana-plugin-plugins-data-public.isearchstart.aggs.md) | <code>AggsStart</code> | agg config sub service [AggsStart](./kibana-plugin-plugins-data-public.aggsstart.md) |
|
||||
| [search](./kibana-plugin-plugins-data-public.isearchstart.search.md) | <code>ISearchGeneric</code> | low level search [ISearchGeneric](./kibana-plugin-plugins-data-public.isearchgeneric.md) |
|
||||
| [searchSource](./kibana-plugin-plugins-data-public.isearchstart.searchsource.md) | <code>ISearchStartSearchSource</code> | high level search [ISearchStartSearchSource](./kibana-plugin-plugins-data-public.isearchstartsearchsource.md) |
|
||||
| [session](./kibana-plugin-plugins-data-public.isearchstart.session.md) | <code>ISessionService</code> | session management |
|
||||
| [showError](./kibana-plugin-plugins-data-public.isearchstart.showerror.md) | <code>(e: Error) => void</code> | |
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ISearchStart](./kibana-plugin-plugins-data-public.isearchstart.md) > [session](./kibana-plugin-plugins-data-public.isearchstart.session.md)
|
||||
|
||||
## ISearchStart.session property
|
||||
|
||||
session management
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
session: ISessionService;
|
||||
```
|
|
@ -7,7 +7,7 @@
|
|||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal: AbortSignal, appAbortSignal?: AbortSignal): Error;
|
||||
protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal: AbortSignal, options?: ISearchOptions): Error;
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
@ -17,7 +17,7 @@ protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal
|
|||
| e | <code>any</code> | |
|
||||
| request | <code>IKibanaSearchRequest</code> | |
|
||||
| timeoutSignal | <code>AbortSignal</code> | |
|
||||
| appAbortSignal | <code>AbortSignal</code> | |
|
||||
| options | <code>ISearchOptions</code> | |
|
||||
|
||||
<b>Returns:</b>
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ export declare class SearchInterceptor
|
|||
| Method | Modifiers | Description |
|
||||
| --- | --- | --- |
|
||||
| [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | |
|
||||
| [handleSearchError(e, request, timeoutSignal, appAbortSignal)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | |
|
||||
| [handleSearchError(e, request, timeoutSignal, options)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | |
|
||||
| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given <code>search</code> method. Overrides the <code>AbortSignal</code> with one that will abort either when <code>cancelPending</code> is called, when the request times out, or when the original <code>AbortSignal</code> is aborted. Updates <code>pendingCount$</code> when the request is started/finalized. |
|
||||
| [showError(e)](./kibana-plugin-plugins-data-public.searchinterceptor.showerror.md) | | |
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ export interface SearchInterceptorDeps
|
|||
| Property | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| [http](./kibana-plugin-plugins-data-public.searchinterceptordeps.http.md) | <code>CoreSetup['http']</code> | |
|
||||
| [session](./kibana-plugin-plugins-data-public.searchinterceptordeps.session.md) | <code>ISessionService</code> | |
|
||||
| [startServices](./kibana-plugin-plugins-data-public.searchinterceptordeps.startservices.md) | <code>Promise<[CoreStart, any, unknown]></code> | |
|
||||
| [toasts](./kibana-plugin-plugins-data-public.searchinterceptordeps.toasts.md) | <code>ToastsSetup</code> | |
|
||||
| [uiSettings](./kibana-plugin-plugins-data-public.searchinterceptordeps.uisettings.md) | <code>CoreSetup['uiSettings']</code> | |
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptorDeps](./kibana-plugin-plugins-data-public.searchinterceptordeps.md) > [session](./kibana-plugin-plugins-data-public.searchinterceptordeps.session.md)
|
||||
|
||||
## SearchInterceptorDeps.session property
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
session: ISessionService;
|
||||
```
|
|
@ -15,5 +15,6 @@ export interface ISearchOptions
|
|||
| Property | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| [abortSignal](./kibana-plugin-plugins-data-server.isearchoptions.abortsignal.md) | <code>AbortSignal</code> | An <code>AbortSignal</code> that allows the caller of <code>search</code> to abort a search request. |
|
||||
| [sessionId](./kibana-plugin-plugins-data-server.isearchoptions.sessionid.md) | <code>string</code> | A session ID, grouping multiple search requests into a single session. |
|
||||
| [strategy](./kibana-plugin-plugins-data-server.isearchoptions.strategy.md) | <code>string</code> | Use this option to force using a specific server side search strategy. Leave empty to use the default strategy. |
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) > [sessionId](./kibana-plugin-plugins-data-server.isearchoptions.sessionid.md)
|
||||
|
||||
## ISearchOptions.sessionId property
|
||||
|
||||
A session ID, grouping multiple search requests into a single session.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
sessionId?: string;
|
||||
```
|
|
@ -144,7 +144,6 @@ export class DashboardAppController {
|
|||
notifications,
|
||||
overlays,
|
||||
chrome,
|
||||
injectedMetadata,
|
||||
fatalErrors,
|
||||
uiSettings,
|
||||
savedObjects,
|
||||
|
@ -527,9 +526,6 @@ export class DashboardAppController {
|
|||
filterManager.getFilters()
|
||||
);
|
||||
|
||||
timefilter.disableTimeRangeSelector();
|
||||
timefilter.disableAutoRefreshSelector();
|
||||
|
||||
const landingPageUrl = () => `#${DashboardConstants.LANDING_PAGE_PATH}`;
|
||||
|
||||
const getDashTitle = () =>
|
||||
|
|
20
src/plugins/data/common/mocks.ts
Normal file
20
src/plugins/data/common/mocks.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { getSessionServiceMock } from './search/session/mocks';
|
|
@ -31,6 +31,11 @@ export interface ISearchOptions {
|
|||
* Use this option to force using a specific server side search strategy. Leave empty to use the default strategy.
|
||||
*/
|
||||
strategy?: string;
|
||||
|
||||
/**
|
||||
* A session ID, grouping multiple search requests into a single session.
|
||||
*/
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export type ISearchRequestParams<T = Record<string, any>> = {
|
||||
|
|
|
@ -23,3 +23,4 @@ export * from './expressions';
|
|||
export * from './search_source';
|
||||
export * from './tabify';
|
||||
export * from './types';
|
||||
export * from './session';
|
||||
|
|
20
src/plugins/data/common/search/session/index.ts
Normal file
20
src/plugins/data/common/search/session/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './types';
|
29
src/plugins/data/common/search/session/mocks.ts
Normal file
29
src/plugins/data/common/search/session/mocks.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { ISessionService } from './types';
|
||||
|
||||
export function getSessionServiceMock(): jest.Mocked<ISessionService> {
|
||||
return {
|
||||
clear: jest.fn(),
|
||||
start: jest.fn(),
|
||||
getSessionId: jest.fn(),
|
||||
getSession$: jest.fn(),
|
||||
};
|
||||
}
|
41
src/plugins/data/common/search/session/types.ts
Normal file
41
src/plugins/data/common/search/session/types.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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 { Observable } from 'rxjs';
|
||||
|
||||
export interface ISessionService {
|
||||
/**
|
||||
* Returns the active session ID
|
||||
* @returns The active session ID
|
||||
*/
|
||||
getSessionId: () => string | undefined;
|
||||
/**
|
||||
* Returns the observable that emits an update every time the session ID changes
|
||||
* @returns `Observable`
|
||||
*/
|
||||
getSession$: () => Observable<string | undefined>;
|
||||
/**
|
||||
* Starts a new session
|
||||
*/
|
||||
start: () => string;
|
||||
/**
|
||||
* Clears the active session.
|
||||
*/
|
||||
clear: () => void;
|
||||
}
|
|
@ -1397,6 +1397,7 @@ export type ISearchGeneric = <SearchStrategyRequest extends IKibanaSearchRequest
|
|||
// @public (undocumented)
|
||||
export interface ISearchOptions {
|
||||
abortSignal?: AbortSignal;
|
||||
sessionId?: string;
|
||||
strategy?: string;
|
||||
}
|
||||
|
||||
|
@ -1412,6 +1413,9 @@ export interface ISearchSetup {
|
|||
//
|
||||
// (undocumented)
|
||||
aggs: AggsSetup;
|
||||
// Warning: (ae-forgotten-export) The symbol "ISessionService" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "ISessionService"
|
||||
session: ISessionService;
|
||||
// Warning: (ae-forgotten-export) The symbol "SearchUsageCollector" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// (undocumented)
|
||||
|
@ -1426,6 +1430,8 @@ export interface ISearchStart {
|
|||
aggs: AggsStart;
|
||||
search: ISearchGeneric;
|
||||
searchSource: ISearchStartSearchSource;
|
||||
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "ISessionService"
|
||||
session: ISessionService;
|
||||
// (undocumented)
|
||||
showError: (e: Error) => void;
|
||||
}
|
||||
|
@ -1995,7 +2001,7 @@ export class SearchInterceptor {
|
|||
// (undocumented)
|
||||
protected getTimeoutMode(): TimeoutErrorMode;
|
||||
// (undocumented)
|
||||
protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal: AbortSignal, appAbortSignal?: AbortSignal): Error;
|
||||
protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal: AbortSignal, options?: ISearchOptions): Error;
|
||||
// @internal
|
||||
protected pendingCount$: BehaviorSubject<number>;
|
||||
// @internal (undocumented)
|
||||
|
@ -2006,8 +2012,8 @@ export class SearchInterceptor {
|
|||
abortSignal?: AbortSignal;
|
||||
timeout?: number;
|
||||
}): {
|
||||
combinedSignal: AbortSignal;
|
||||
timeoutSignal: AbortSignal;
|
||||
combinedSignal: AbortSignal;
|
||||
cleanup: () => void;
|
||||
};
|
||||
// (undocumented)
|
||||
|
@ -2021,6 +2027,8 @@ export interface SearchInterceptorDeps {
|
|||
// (undocumented)
|
||||
http: CoreSetup_2['http'];
|
||||
// (undocumented)
|
||||
session: ISessionService;
|
||||
// (undocumented)
|
||||
startServices: Promise<[CoreStart, any, unknown]>;
|
||||
// (undocumented)
|
||||
toasts: ToastsSetup;
|
||||
|
|
|
@ -149,7 +149,9 @@ const handleCourierRequest = async ({
|
|||
request.stats(getRequestInspectorStats(requestSearchSource));
|
||||
|
||||
try {
|
||||
const response = await requestSearchSource.fetch({ abortSignal });
|
||||
const response = await requestSearchSource.fetch({
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
request.stats(getResponseInspectorStats(response, searchSource)).ok({ json: response });
|
||||
|
||||
|
|
|
@ -20,11 +20,13 @@
|
|||
import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks';
|
||||
import { searchSourceMock } from './search_source/mocks';
|
||||
import { ISearchSetup, ISearchStart } from './types';
|
||||
import { getSessionServiceMock } from '../../common/mocks';
|
||||
|
||||
function createSetupContract(): jest.Mocked<ISearchSetup> {
|
||||
return {
|
||||
aggs: searchAggsSetupMock(),
|
||||
__enhance: jest.fn(),
|
||||
session: getSessionServiceMock(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -33,6 +35,7 @@ function createStartContract(): jest.Mocked<ISearchStart> {
|
|||
aggs: searchAggsStartMock(),
|
||||
search: jest.fn(),
|
||||
showError: jest.fn(),
|
||||
session: getSessionServiceMock(),
|
||||
searchSource: searchSourceMock.createStartContract(),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -17,12 +17,14 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { CoreSetup } from '../../../../core/public';
|
||||
import { CoreSetup, CoreStart } from '../../../../core/public';
|
||||
import { coreMock } from '../../../../core/public/mocks';
|
||||
import { IEsSearchRequest } from '../../common/search';
|
||||
import { SearchInterceptor } from './search_interceptor';
|
||||
import { AbortError } from '../../common';
|
||||
import { SearchTimeoutError, PainlessError } from './errors';
|
||||
import { SearchTimeoutError, PainlessError, TimeoutErrorMode } from './errors';
|
||||
import { searchServiceMock } from './mocks';
|
||||
import { ISearchStart } from '.';
|
||||
|
||||
let searchInterceptor: SearchInterceptor;
|
||||
let mockCoreSetup: MockedKeys<CoreSetup>;
|
||||
|
@ -31,13 +33,61 @@ const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
|||
jest.useFakeTimers();
|
||||
|
||||
describe('SearchInterceptor', () => {
|
||||
let searchMock: jest.Mocked<ISearchStart>;
|
||||
let mockCoreStart: MockedKeys<CoreStart>;
|
||||
beforeEach(() => {
|
||||
mockCoreSetup = coreMock.createSetup();
|
||||
mockCoreStart = coreMock.createStart();
|
||||
searchMock = searchServiceMock.createStartContract();
|
||||
searchInterceptor = new SearchInterceptor({
|
||||
toasts: mockCoreSetup.notifications.toasts,
|
||||
startServices: mockCoreSetup.getStartServices(),
|
||||
startServices: new Promise((resolve) => {
|
||||
resolve([mockCoreStart, {}, {}]);
|
||||
}),
|
||||
uiSettings: mockCoreSetup.uiSettings,
|
||||
http: mockCoreSetup.http,
|
||||
session: searchMock.session,
|
||||
});
|
||||
});
|
||||
|
||||
describe('showError', () => {
|
||||
test('Ignores an AbortError', async () => {
|
||||
searchInterceptor.showError(new AbortError());
|
||||
expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled();
|
||||
expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('Ignores a SearchTimeoutError', async () => {
|
||||
searchInterceptor.showError(new SearchTimeoutError(new Error(), TimeoutErrorMode.UPGRADE));
|
||||
expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled();
|
||||
expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('Renders a PainlessError', async () => {
|
||||
searchInterceptor.showError(
|
||||
new PainlessError(
|
||||
{
|
||||
body: {
|
||||
attributes: {
|
||||
error: {
|
||||
failed_shards: {
|
||||
reason: 'bananas',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
{} as any
|
||||
)
|
||||
);
|
||||
expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1);
|
||||
expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('Renders a general error', async () => {
|
||||
searchInterceptor.showError(new Error('Oopsy'));
|
||||
expect(mockCoreSetup.notifications.toasts.addDanger).not.toBeCalled();
|
||||
expect(mockCoreSetup.notifications.toasts.addError).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -49,149 +99,172 @@ describe('SearchInterceptor', () => {
|
|||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest);
|
||||
|
||||
const result = await response.toPromise();
|
||||
expect(result).toBe(mockResponse);
|
||||
expect(response.toPromise()).resolves.toBe(mockResponse);
|
||||
});
|
||||
|
||||
test('Observable should fail if fetch has an internal error', async () => {
|
||||
const mockResponse: any = { result: 500, message: 'Internal Error' };
|
||||
mockCoreSetup.http.fetch.mockRejectedValueOnce(mockResponse);
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest);
|
||||
describe('Should throw typed errors', () => {
|
||||
test('Observable should fail if fetch has an internal error', async () => {
|
||||
const mockResponse: any = new Error('Internal Error');
|
||||
mockCoreSetup.http.fetch.mockRejectedValue(mockResponse);
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest);
|
||||
await expect(response.toPromise()).rejects.toThrow('Internal Error');
|
||||
});
|
||||
|
||||
try {
|
||||
await response.toPromise();
|
||||
} catch (e) {
|
||||
expect(e).toBe(mockResponse);
|
||||
}
|
||||
});
|
||||
|
||||
test('Should throw SearchTimeoutError on server timeout AND show toast', async (done) => {
|
||||
const mockResponse: any = {
|
||||
result: 500,
|
||||
body: {
|
||||
message: 'Request timed out',
|
||||
},
|
||||
};
|
||||
mockCoreSetup.http.fetch.mockRejectedValueOnce(mockResponse);
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest);
|
||||
|
||||
try {
|
||||
await response.toPromise();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SearchTimeoutError);
|
||||
expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1);
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
test('Search error should be debounced', async (done) => {
|
||||
const mockResponse: any = {
|
||||
result: 500,
|
||||
body: {
|
||||
message: 'Request timed out',
|
||||
},
|
||||
};
|
||||
mockCoreSetup.http.fetch.mockRejectedValue(mockResponse);
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
try {
|
||||
await searchInterceptor.search(mockRequest).toPromise();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SearchTimeoutError);
|
||||
try {
|
||||
await searchInterceptor.search(mockRequest).toPromise();
|
||||
} catch (e2) {
|
||||
expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1);
|
||||
done();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Should throw Painless error on server error with OSS format', async (done) => {
|
||||
const mockResponse: any = {
|
||||
result: 500,
|
||||
body: {
|
||||
attributes: {
|
||||
error: {
|
||||
failed_shards: [
|
||||
{
|
||||
reason: {
|
||||
lang: 'painless',
|
||||
script_stack: ['a', 'b'],
|
||||
reason: 'banana',
|
||||
},
|
||||
},
|
||||
],
|
||||
describe('Should handle Timeout errors', () => {
|
||||
test('Should throw SearchTimeoutError on server timeout AND show toast', async () => {
|
||||
const mockResponse: any = {
|
||||
result: 500,
|
||||
body: {
|
||||
message: 'Request timed out',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockCoreSetup.http.fetch.mockRejectedValueOnce(mockResponse);
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest);
|
||||
};
|
||||
mockCoreSetup.http.fetch.mockRejectedValueOnce(mockResponse);
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest);
|
||||
await expect(response.toPromise()).rejects.toThrow(SearchTimeoutError);
|
||||
expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
try {
|
||||
await response.toPromise();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(PainlessError);
|
||||
done();
|
||||
}
|
||||
});
|
||||
test('Timeout error should show multiple times if not in a session', async () => {
|
||||
const mockResponse: any = {
|
||||
result: 500,
|
||||
body: {
|
||||
message: 'Request timed out',
|
||||
},
|
||||
};
|
||||
mockCoreSetup.http.fetch.mockRejectedValue(mockResponse);
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
|
||||
test('Observable should fail if user aborts (test merged signal)', async () => {
|
||||
const abortController = new AbortController();
|
||||
mockCoreSetup.http.fetch.mockImplementationOnce((options: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => {
|
||||
reject(new AbortError());
|
||||
});
|
||||
await expect(searchInterceptor.search(mockRequest).toPromise()).rejects.toThrow(
|
||||
SearchTimeoutError
|
||||
);
|
||||
await expect(searchInterceptor.search(mockRequest).toPromise()).rejects.toThrow(
|
||||
SearchTimeoutError
|
||||
);
|
||||
expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
setTimeout(resolve, 500);
|
||||
test('Timeout error should show once per each session', async () => {
|
||||
const mockResponse: any = {
|
||||
result: 500,
|
||||
body: {
|
||||
message: 'Request timed out',
|
||||
},
|
||||
};
|
||||
mockCoreSetup.http.fetch.mockRejectedValue(mockResponse);
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
|
||||
await expect(
|
||||
searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise()
|
||||
).rejects.toThrow(SearchTimeoutError);
|
||||
await expect(
|
||||
searchInterceptor.search(mockRequest, { sessionId: 'def' }).toPromise()
|
||||
).rejects.toThrow(SearchTimeoutError);
|
||||
expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
test('Timeout error should show once in a single session', async () => {
|
||||
const mockResponse: any = {
|
||||
result: 500,
|
||||
body: {
|
||||
message: 'Request timed out',
|
||||
},
|
||||
};
|
||||
mockCoreSetup.http.fetch.mockRejectedValue(mockResponse);
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
await expect(
|
||||
searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise()
|
||||
).rejects.toThrow(SearchTimeoutError);
|
||||
await expect(
|
||||
searchInterceptor.search(mockRequest, { sessionId: 'abc' }).toPromise()
|
||||
).rejects.toThrow(SearchTimeoutError);
|
||||
expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest, {
|
||||
abortSignal: abortController.signal,
|
||||
|
||||
test('Should throw Painless error on server error with OSS format', async () => {
|
||||
const mockResponse: any = {
|
||||
result: 500,
|
||||
body: {
|
||||
attributes: {
|
||||
error: {
|
||||
failed_shards: [
|
||||
{
|
||||
reason: {
|
||||
lang: 'painless',
|
||||
script_stack: ['a', 'b'],
|
||||
reason: 'banana',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockCoreSetup.http.fetch.mockRejectedValueOnce(mockResponse);
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest);
|
||||
await expect(response.toPromise()).rejects.toThrow(PainlessError);
|
||||
});
|
||||
|
||||
const next = jest.fn();
|
||||
const error = (e: any) => {
|
||||
expect(next).not.toBeCalled();
|
||||
expect(e).toBeInstanceOf(AbortError);
|
||||
};
|
||||
response.subscribe({ next, error });
|
||||
setTimeout(() => abortController.abort(), 200);
|
||||
jest.advanceTimersByTime(5000);
|
||||
test('Observable should fail if user aborts (test merged signal)', async () => {
|
||||
const abortController = new AbortController();
|
||||
mockCoreSetup.http.fetch.mockImplementationOnce((options: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => {
|
||||
reject(new AbortError());
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
});
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
});
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest, {
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
test('Immediately aborts if passed an aborted abort signal', async (done) => {
|
||||
const abort = new AbortController();
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest, { abortSignal: abort.signal });
|
||||
abort.abort();
|
||||
const next = jest.fn();
|
||||
const error = (e: any) => {
|
||||
expect(next).not.toBeCalled();
|
||||
expect(e).toBeInstanceOf(AbortError);
|
||||
};
|
||||
response.subscribe({ next, error });
|
||||
setTimeout(() => abortController.abort(), 200);
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
const error = (e: any) => {
|
||||
expect(e).toBeInstanceOf(AbortError);
|
||||
expect(mockCoreSetup.http.fetch).not.toBeCalled();
|
||||
done();
|
||||
};
|
||||
response.subscribe({ error });
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
test('Immediately aborts if passed an aborted abort signal', async (done) => {
|
||||
const abort = new AbortController();
|
||||
const mockRequest: IEsSearchRequest = {
|
||||
params: {},
|
||||
};
|
||||
const response = searchInterceptor.search(mockRequest, { abortSignal: abort.signal });
|
||||
abort.abort();
|
||||
|
||||
const error = (e: any) => {
|
||||
expect(e).toBeInstanceOf(AbortError);
|
||||
expect(mockCoreSetup.http.fetch).not.toBeCalled();
|
||||
done();
|
||||
};
|
||||
response.subscribe({ error });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { get, trimEnd, debounce } from 'lodash';
|
||||
import { get, memoize, trimEnd } from 'lodash';
|
||||
import { BehaviorSubject, throwError, timer, defer, from, Observable, NEVER } from 'rxjs';
|
||||
import { catchError, finalize } from 'rxjs/operators';
|
||||
import { CoreStart, CoreSetup, ToastsSetup } from 'kibana/public';
|
||||
|
@ -28,6 +28,7 @@ import {
|
|||
IKibanaSearchResponse,
|
||||
ISearchOptions,
|
||||
ES_SEARCH_STRATEGY,
|
||||
ISessionService,
|
||||
} from '../../common';
|
||||
import { SearchUsageCollector } from './collectors';
|
||||
import { SearchTimeoutError, PainlessError, isPainlessError, TimeoutErrorMode } from './errors';
|
||||
|
@ -39,6 +40,7 @@ export interface SearchInterceptorDeps {
|
|||
startServices: Promise<[CoreStart, any, unknown]>;
|
||||
toasts: ToastsSetup;
|
||||
usageCollector?: SearchUsageCollector;
|
||||
session: ISessionService;
|
||||
}
|
||||
|
||||
export class SearchInterceptor {
|
||||
|
@ -86,16 +88,17 @@ export class SearchInterceptor {
|
|||
e: any,
|
||||
request: IKibanaSearchRequest,
|
||||
timeoutSignal: AbortSignal,
|
||||
appAbortSignal?: AbortSignal
|
||||
options?: ISearchOptions
|
||||
): Error {
|
||||
if (timeoutSignal.aborted || get(e, 'body.message') === 'Request timed out') {
|
||||
// Handle a client or a server side timeout
|
||||
const err = new SearchTimeoutError(e, this.getTimeoutMode());
|
||||
|
||||
// Show the timeout error here, so that it's shown regardless of how an application chooses to handle errors.
|
||||
this.showTimeoutError(err);
|
||||
// The timeout error is shown any time a request times out, or once per session, if the request is part of a session.
|
||||
this.showTimeoutError(err, options?.sessionId);
|
||||
return err;
|
||||
} else if (appAbortSignal?.aborted) {
|
||||
} else if (options?.abortSignal?.aborted) {
|
||||
// In the case an application initiated abort, throw the existing AbortError.
|
||||
return e;
|
||||
} else if (isPainlessError(e)) {
|
||||
|
@ -162,27 +165,37 @@ export class SearchInterceptor {
|
|||
combinedSignal.addEventListener('abort', cleanup);
|
||||
|
||||
return {
|
||||
combinedSignal,
|
||||
timeoutSignal,
|
||||
combinedSignal,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
private showTimeoutErrorToast = (e: SearchTimeoutError, sessionId?: string) => {
|
||||
this.deps.toasts.addDanger({
|
||||
title: 'Timed out',
|
||||
text: toMountPoint(e.getErrorMessage(this.application)),
|
||||
});
|
||||
};
|
||||
|
||||
private showTimeoutErrorMemoized = memoize(
|
||||
this.showTimeoutErrorToast,
|
||||
(_: SearchTimeoutError, sessionId: string) => {
|
||||
return sessionId;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Right now we are throttling but we will hook this up with background sessions to show only one
|
||||
* error notification per session.
|
||||
* Show one error notification per session.
|
||||
* @internal
|
||||
*/
|
||||
private showTimeoutError = debounce(
|
||||
(e: SearchTimeoutError) => {
|
||||
this.deps.toasts.addDanger({
|
||||
title: 'Timed out',
|
||||
text: toMountPoint(e.getErrorMessage(this.application)),
|
||||
});
|
||||
},
|
||||
30000,
|
||||
{ leading: true, trailing: false }
|
||||
);
|
||||
private showTimeoutError = (e: SearchTimeoutError, sessionId?: string) => {
|
||||
if (sessionId) {
|
||||
this.showTimeoutErrorMemoized(e, sessionId);
|
||||
} else {
|
||||
this.showTimeoutErrorToast(e, sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort
|
||||
|
@ -207,12 +220,9 @@ export class SearchInterceptor {
|
|||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
this.pendingCount$.next(this.pendingCount$.getValue() + 1);
|
||||
|
||||
return this.runSearch(request, combinedSignal, options?.strategy).pipe(
|
||||
catchError((e: any) => {
|
||||
return throwError(
|
||||
this.handleSearchError(e, request, timeoutSignal, options?.abortSignal)
|
||||
);
|
||||
catchError((e: Error) => {
|
||||
return throwError(this.handleSearchError(e, request, timeoutSignal, options));
|
||||
}),
|
||||
finalize(() => {
|
||||
this.pendingCount$.next(this.pendingCount$.getValue() - 1);
|
||||
|
|
|
@ -31,6 +31,7 @@ import {
|
|||
ISearchOptions,
|
||||
SearchSourceService,
|
||||
SearchSourceDependencies,
|
||||
ISessionService,
|
||||
} from '../../common/search';
|
||||
import { getCallMsearch } from './legacy';
|
||||
import { AggsService, AggsStartDependencies } from './aggs';
|
||||
|
@ -40,6 +41,7 @@ import { SearchUsageCollector, createUsageCollector } from './collectors';
|
|||
import { UsageCollectionSetup } from '../../../usage_collection/public';
|
||||
import { esdsl, esRawResponse } from './expressions';
|
||||
import { ExpressionsSetup } from '../../../expressions/public';
|
||||
import { SessionService } from './session_service';
|
||||
import { ConfigSchema } from '../../config';
|
||||
import {
|
||||
SHARD_DELAY_AGG_NAME,
|
||||
|
@ -64,6 +66,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
private readonly searchSourceService = new SearchSourceService();
|
||||
private searchInterceptor!: ISearchInterceptor;
|
||||
private usageCollector?: SearchUsageCollector;
|
||||
private sessionService!: ISessionService;
|
||||
|
||||
constructor(private initializerContext: PluginInitializerContext<ConfigSchema>) {}
|
||||
|
||||
|
@ -73,6 +76,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
): ISearchSetup {
|
||||
this.usageCollector = createUsageCollector(getStartServices, usageCollection);
|
||||
|
||||
this.sessionService = new SessionService(this.initializerContext, getStartServices);
|
||||
/**
|
||||
* A global object that intercepts all searches and provides convenience methods for cancelling
|
||||
* all pending search requests, as well as getting the number of pending search requests.
|
||||
|
@ -83,6 +87,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
uiSettings,
|
||||
startServices: getStartServices(),
|
||||
usageCollector: this.usageCollector!,
|
||||
session: this.sessionService,
|
||||
});
|
||||
|
||||
expressions.registerFunction(esdsl);
|
||||
|
@ -104,6 +109,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
__enhance: (enhancements: SearchEnhancements) => {
|
||||
this.searchInterceptor = enhancements.searchInterceptor;
|
||||
},
|
||||
session: this.sessionService,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -142,6 +148,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
|
|||
showError: (e: Error) => {
|
||||
this.searchInterceptor.showError(e);
|
||||
},
|
||||
session: this.sessionService,
|
||||
searchSource: this.searchSourceService.start(indexPatterns, searchSourceDependencies),
|
||||
};
|
||||
}
|
||||
|
|
43
src/plugins/data/public/search/session_service.test.ts
Normal file
43
src/plugins/data/public/search/session_service.test.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { SessionService } from './session_service';
|
||||
import { ISessionService } from '../../common';
|
||||
import { coreMock } from '../../../../core/public/mocks';
|
||||
|
||||
describe('Session service', () => {
|
||||
let sessionService: ISessionService;
|
||||
|
||||
beforeEach(() => {
|
||||
const initializerContext = coreMock.createPluginInitializerContext();
|
||||
sessionService = new SessionService(
|
||||
initializerContext,
|
||||
coreMock.createSetup().getStartServices
|
||||
);
|
||||
});
|
||||
|
||||
describe('Session management', () => {
|
||||
it('Creates and clears a session', async () => {
|
||||
sessionService.start();
|
||||
expect(sessionService.getSessionId()).not.toBeUndefined();
|
||||
sessionService.clear();
|
||||
expect(sessionService.getSessionId()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
80
src/plugins/data/public/search/session_service.ts
Normal file
80
src/plugins/data/public/search/session_service.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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 uuid from 'uuid';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { PluginInitializerContext, StartServicesAccessor } from 'kibana/public';
|
||||
import { ISessionService } from '../../common/search';
|
||||
import { ConfigSchema } from '../../config';
|
||||
|
||||
export class SessionService implements ISessionService {
|
||||
private sessionId?: string;
|
||||
private session$: Subject<string | undefined> = new Subject();
|
||||
private appChangeSubscription$?: Subscription;
|
||||
private curApp?: string;
|
||||
|
||||
constructor(
|
||||
initializerContext: PluginInitializerContext<ConfigSchema>,
|
||||
getStartServices: StartServicesAccessor
|
||||
) {
|
||||
/*
|
||||
Make sure that apps don't leave sessions open.
|
||||
*/
|
||||
getStartServices().then(([coreStart]) => {
|
||||
this.appChangeSubscription$ = coreStart.application.currentAppId$.subscribe((appName) => {
|
||||
if (this.sessionId) {
|
||||
const message = `Application '${this.curApp}' had an open session while navigating`;
|
||||
if (initializerContext.env.mode.dev) {
|
||||
// TODO: This setTimeout is necessary due to a race condition while navigating.
|
||||
setTimeout(() => {
|
||||
coreStart.fatalErrors.add(message);
|
||||
}, 100);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(message);
|
||||
}
|
||||
}
|
||||
this.curApp = appName;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.appChangeSubscription$?.unsubscribe();
|
||||
}
|
||||
|
||||
public getSessionId() {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
public getSession$() {
|
||||
return this.session$.asObservable();
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.sessionId = uuid.v4();
|
||||
this.session$.next(this.sessionId);
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.sessionId = undefined;
|
||||
this.session$.next(this.sessionId);
|
||||
}
|
||||
}
|
|
@ -21,7 +21,7 @@ import { PackageInfo } from 'kibana/server';
|
|||
import { ISearchInterceptor } from './search_interceptor';
|
||||
import { SearchUsageCollector } from './collectors';
|
||||
import { AggsSetup, AggsSetupDependencies, AggsStartDependencies, AggsStart } from './aggs';
|
||||
import { ISearchGeneric, ISearchStartSearchSource } from '../../common/search';
|
||||
import { ISearchGeneric, ISessionService, ISearchStartSearchSource } from '../../common/search';
|
||||
import { IndexPatternsContract } from '../../common/index_patterns/index_patterns';
|
||||
import { UsageCollectionSetup } from '../../../usage_collection/public';
|
||||
|
||||
|
@ -38,6 +38,11 @@ export interface SearchEnhancements {
|
|||
export interface ISearchSetup {
|
||||
aggs: AggsSetup;
|
||||
usageCollector?: SearchUsageCollector;
|
||||
/**
|
||||
* session management
|
||||
* {@link ISessionService}
|
||||
*/
|
||||
session: ISessionService;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
|
@ -67,6 +72,11 @@ export interface ISearchStart {
|
|||
* {@link ISearchStartSearchSource}
|
||||
*/
|
||||
searchSource: ISearchStartSearchSource;
|
||||
/**
|
||||
* session management
|
||||
* {@link ISessionService}
|
||||
*/
|
||||
session: ISessionService;
|
||||
}
|
||||
|
||||
export { SEARCH_EVENT_TYPE } from './collectors';
|
||||
|
|
|
@ -689,6 +689,7 @@ export class IndexPatternsService implements Plugin_3<void, IndexPatternsService
|
|||
// @public (undocumented)
|
||||
export interface ISearchOptions {
|
||||
abortSignal?: AbortSignal;
|
||||
sessionId?: string;
|
||||
strategy?: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -332,6 +332,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
|
|||
if (abortController) abortController.abort();
|
||||
savedSearch.destroy();
|
||||
subscriptions.unsubscribe();
|
||||
data.search.session.clear();
|
||||
appStateUnsubscribe();
|
||||
stopStateSync();
|
||||
stopSyncingGlobalStateWithUrl();
|
||||
|
@ -788,6 +789,8 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
|
|||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
|
||||
const sessionId = data.search.session.start();
|
||||
|
||||
$scope
|
||||
.updateDataSource()
|
||||
.then(setupVisualization)
|
||||
|
@ -796,6 +799,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
|
|||
logInspectorRequest();
|
||||
return $scope.searchSource.fetch({
|
||||
abortSignal: abortController.signal,
|
||||
sessionId,
|
||||
});
|
||||
})
|
||||
.then(onResults)
|
||||
|
|
|
@ -282,6 +282,10 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
|
|||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
async hasIndexPattern(name: string) {
|
||||
return await find.existsByLinkText(name);
|
||||
}
|
||||
|
||||
async clickIndexPatternByName(name: string) {
|
||||
const indexLink = await find.byXPath(`//a[descendant::*[text()='${name}']]`);
|
||||
await indexLink.click();
|
||||
|
@ -324,6 +328,13 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
|
|||
await retry.try(async () => {
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await this.clickKibanaIndexPatterns();
|
||||
const exists = await this.hasIndexPattern(indexPatternName);
|
||||
|
||||
if (exists) {
|
||||
await this.clickIndexPatternByName(indexPatternName);
|
||||
return;
|
||||
}
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await this.clickAddNewIndexPatternButton();
|
||||
if (!isStandardIndexPattern) {
|
||||
|
|
|
@ -71,6 +71,12 @@ export function ToastsProvider({ getService }: FtrProviderContext) {
|
|||
private async getGlobalToastList() {
|
||||
return await testSubjects.find('globalToastList');
|
||||
}
|
||||
|
||||
public async getToastCount() {
|
||||
const list = await this.getGlobalToastList();
|
||||
const toasts = await list.findAllByCssSelector(`.euiToast`);
|
||||
return toasts.length;
|
||||
}
|
||||
}
|
||||
|
||||
return new Toasts();
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "session_notifications",
|
||||
"version": "0.0.1",
|
||||
"kibanaVersion": "kibana",
|
||||
"configPath": ["session_notifications"],
|
||||
"server": false,
|
||||
"ui": true,
|
||||
"requiredPlugins": ["data", "navigation"]
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "session_notifications",
|
||||
"version": "1.0.0",
|
||||
"main": "target/test/plugin_functional/plugins/session_notifications",
|
||||
"kibana": {
|
||||
"version": "kibana",
|
||||
"templateVersion": "1.0.0"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"kbn": "node ../../../../scripts/kbn.js",
|
||||
"build": "rm -rf './target' && tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "4.0.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 { PluginInitializer } from 'kibana/public';
|
||||
import { SessionNotificationsPlugin } from './plugin';
|
||||
|
||||
export const plugin: PluginInitializer<void, void> = () => new SessionNotificationsPlugin();
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { CoreSetup, CoreStart, Plugin } from 'kibana/public';
|
||||
import { AppPluginDependenciesStart, AppPluginDependenciesSetup } from './types';
|
||||
|
||||
export class SessionNotificationsPlugin implements Plugin {
|
||||
private sessionIds: Array<string | undefined> = [];
|
||||
public setup(core: CoreSetup, { navigation }: AppPluginDependenciesSetup) {
|
||||
const showSessions = {
|
||||
id: 'showSessionsButton',
|
||||
label: 'Show Sessions',
|
||||
description: 'Sessions',
|
||||
run: () => {
|
||||
core.notifications.toasts.addInfo(this.sessionIds.join(','), {
|
||||
toastLifeTimeMs: 50000,
|
||||
});
|
||||
},
|
||||
tooltip: () => {
|
||||
return this.sessionIds.join(',');
|
||||
},
|
||||
testId: 'showSessionsButton',
|
||||
};
|
||||
|
||||
navigation.registerMenuItem(showSessions);
|
||||
|
||||
const clearSessions = {
|
||||
id: 'clearSessionsButton',
|
||||
label: 'Clear Sessions',
|
||||
description: 'Sessions',
|
||||
run: () => {
|
||||
this.sessionIds.length = 0;
|
||||
},
|
||||
testId: 'clearSessionsButton',
|
||||
};
|
||||
|
||||
navigation.registerMenuItem(clearSessions);
|
||||
}
|
||||
|
||||
public start(core: CoreStart, { data }: AppPluginDependenciesStart) {
|
||||
core.application.currentAppId$.subscribe(() => {
|
||||
this.sessionIds.length = 0;
|
||||
});
|
||||
|
||||
data.search.session.getSession$().subscribe((sessionId?: string) => {
|
||||
this.sessionIds.push(sessionId);
|
||||
});
|
||||
}
|
||||
public stop() {}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 { NavigationPublicPluginSetup } from '../../../../../src/plugins/navigation/public';
|
||||
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
|
||||
|
||||
export interface AppPluginDependenciesSetup {
|
||||
navigation: NavigationPublicPluginSetup;
|
||||
}
|
||||
export interface AppPluginDependenciesStart {
|
||||
data: DataPublicPluginStart;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./target",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"index.ts",
|
||||
"public/**/*.ts",
|
||||
"public/**/*.tsx",
|
||||
"server/**/*.ts",
|
||||
"../../../../typings/**/*",
|
||||
],
|
||||
"exclude": [],
|
||||
"references": [
|
||||
{ "path": "../../../../src/core/tsconfig.json" }
|
||||
]
|
||||
}
|
|
@ -35,7 +35,9 @@ export default function ({
|
|||
await PageObjects.common.navigateToApp('settings');
|
||||
await PageObjects.settings.createIndexPattern('shakespeare', '');
|
||||
});
|
||||
loadTestFile(require.resolve('./index_patterns'));
|
||||
|
||||
loadTestFile(require.resolve('./search'));
|
||||
loadTestFile(require.resolve('./session'));
|
||||
loadTestFile(require.resolve('./index_patterns'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -23,7 +23,9 @@ import '../../plugins/core_provider_plugin/types';
|
|||
export default function ({ getService }: PluginFunctionalProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('index patterns', function () {
|
||||
// skipping the tests as it deletes index patterns created by other test causing unexpected failures
|
||||
// https://github.com/elastic/kibana/issues/79886
|
||||
describe.skip('index patterns', function () {
|
||||
let indexPatternId = '';
|
||||
|
||||
it('can get all ids', async () => {
|
||||
|
|
83
test/plugin_functional/test_suites/data_plugin/session.ts
Normal file
83
test/plugin_functional/test_suites/data_plugin/session.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { PluginFunctionalProviderContext } from '../../services';
|
||||
|
||||
export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) {
|
||||
const PageObjects = getPageObjects(['common', 'header', 'dashboard', 'discover', 'timePicker']);
|
||||
const filterBar = getService('filterBar');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const toasts = getService('toasts');
|
||||
|
||||
const getSessionIds = async () => {
|
||||
const sessionsBtn = await testSubjects.find('showSessionsButton');
|
||||
await sessionsBtn.click();
|
||||
const toast = await toasts.getToastElement(1);
|
||||
const sessionIds = await toast.getVisibleText();
|
||||
return sessionIds.split(',');
|
||||
};
|
||||
|
||||
describe('Session management', function describeIndexTests() {
|
||||
describe('Discover', () => {
|
||||
before(async () => {
|
||||
await PageObjects.common.navigateToApp('discover');
|
||||
await testSubjects.click('clearSessionsButton');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testSubjects.click('clearSessionsButton');
|
||||
await toasts.dismissAllToasts();
|
||||
});
|
||||
|
||||
it('Starts on index pattern select', async () => {
|
||||
await PageObjects.discover.selectIndexPattern('shakespeare');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
const sessionIds = await getSessionIds();
|
||||
|
||||
// Discover calls destroy on index pattern change, which explicitly closes a session
|
||||
expect(sessionIds.length).to.be(2);
|
||||
expect(sessionIds[0].length).to.be(0);
|
||||
expect(sessionIds[1].length).not.to.be(0);
|
||||
});
|
||||
|
||||
it('Starts on a refresh', async () => {
|
||||
await testSubjects.click('querySubmitButton');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
const sessionIds = await getSessionIds();
|
||||
expect(sessionIds.length).to.be(1);
|
||||
});
|
||||
|
||||
it('Starts a new session on sort', async () => {
|
||||
await PageObjects.discover.clickFieldListItemAdd('speaker');
|
||||
await PageObjects.discover.clickFieldSort('speaker');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
const sessionIds = await getSessionIds();
|
||||
expect(sessionIds.length).to.be(1);
|
||||
});
|
||||
|
||||
it('Starts a new session on filter change', async () => {
|
||||
await filterBar.addFilter('line_number', 'is', '4.3.108');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
const sessionIds = await getSessionIds();
|
||||
expect(sessionIds.length).to.be(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -40,6 +40,7 @@ export class DataEnhancedPlugin
|
|||
uiSettings: core.uiSettings,
|
||||
startServices: core.getStartServices(),
|
||||
usageCollector: data.search.usageCollector,
|
||||
session: data.search.session,
|
||||
});
|
||||
|
||||
data.__enhance({
|
||||
|
|
|
@ -9,6 +9,7 @@ import { EnhancedSearchInterceptor } from './search_interceptor';
|
|||
import { CoreSetup, CoreStart } from 'kibana/public';
|
||||
import { AbortError, UI_SETTINGS } from '../../../../../src/plugins/data/common';
|
||||
import { SearchTimeoutError } from 'src/plugins/data/public';
|
||||
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
|
||||
|
||||
const timeTravel = (msToRun = 0) => {
|
||||
jest.advanceTimersByTime(msToRun);
|
||||
|
@ -43,6 +44,7 @@ describe('EnhancedSearchInterceptor', () => {
|
|||
beforeEach(() => {
|
||||
mockCoreSetup = coreMock.createSetup();
|
||||
mockCoreStart = coreMock.createStart();
|
||||
const dataPluginMockStart = dataPluginMock.createStartContract();
|
||||
|
||||
mockCoreSetup.uiSettings.get.mockImplementation((name: string) => {
|
||||
switch (name) {
|
||||
|
@ -77,6 +79,7 @@ describe('EnhancedSearchInterceptor', () => {
|
|||
http: mockCoreSetup.http,
|
||||
uiSettings: mockCoreSetup.uiSettings,
|
||||
usageCollector: mockUsageCollector,
|
||||
session: dataPluginMockStart.search.session,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -98,7 +98,7 @@ export class EnhancedSearchInterceptor extends SearchInterceptor {
|
|||
if (id !== undefined) {
|
||||
this.deps.http.delete(`/internal/search/${strategy}/${id}`);
|
||||
}
|
||||
return throwError(this.handleSearchError(e, request, timeoutSignal, options?.abortSignal));
|
||||
return throwError(this.handleSearchError(e, request, timeoutSignal, options));
|
||||
}),
|
||||
finalize(() => {
|
||||
this.pendingCount$.next(this.pendingCount$.getValue() - 1);
|
||||
|
|
|
@ -21,6 +21,7 @@ import { CreateTimeline, UpdateTimelineLoading } from './types';
|
|||
import { Ecs } from '../../../../common/ecs';
|
||||
import { TimelineId, TimelineType, TimelineStatus } from '../../../../common/types/timeline';
|
||||
import { ISearchStart } from '../../../../../../../src/plugins/data/public';
|
||||
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
|
||||
|
||||
jest.mock('apollo-client');
|
||||
|
||||
|
@ -29,7 +30,7 @@ describe('alert actions', () => {
|
|||
const unix = moment(anchor).valueOf();
|
||||
let createTimeline: CreateTimeline;
|
||||
let updateTimelineIsLoading: UpdateTimelineLoading;
|
||||
let searchStrategyClient: ISearchStart;
|
||||
let searchStrategyClient: jest.Mocked<ISearchStart>;
|
||||
let clock: sinon.SinonFakeTimers;
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -42,11 +43,13 @@ describe('alert actions', () => {
|
|||
|
||||
createTimeline = jest.fn() as jest.Mocked<CreateTimeline>;
|
||||
updateTimelineIsLoading = jest.fn() as jest.Mocked<UpdateTimelineLoading>;
|
||||
|
||||
searchStrategyClient = {
|
||||
aggs: {} as ISearchStart['aggs'],
|
||||
showError: jest.fn(),
|
||||
search: jest.fn().mockResolvedValue({ data: mockTimelineDetails }),
|
||||
searchSource: {} as ISearchStart['searchSource'],
|
||||
session: dataPluginMock.createStartContract().search.session,
|
||||
};
|
||||
|
||||
jest.spyOn(apolloClient, 'query').mockImplementation((obj) => {
|
||||
|
|
Loading…
Reference in a new issue