[Search] Client side session service (#76889) (#80808)

* 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:
Liza Katz 2020-10-16 14:57:32 +03:00 committed by GitHub
parent cf9c0e1192
commit 59e60118bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 863 additions and 169 deletions

View file

@ -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. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [ISearchOptions](./kibana-plugin-plugins-data-public.isearchoptions.md) &gt; [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;
```

View file

@ -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> | |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [ISearchSetup](./kibana-plugin-plugins-data-public.isearchsetup.md) &gt; [session](./kibana-plugin-plugins-data-public.isearchsetup.session.md)
## ISearchSetup.session property
session management
<b>Signature:</b>
```typescript
session: ISessionService;
```

View file

@ -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) =&gt; void</code> | |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [ISearchStart](./kibana-plugin-plugins-data-public.isearchstart.md) &gt; [session](./kibana-plugin-plugins-data-public.isearchstart.session.md)
## ISearchStart.session property
session management
<b>Signature:</b>
```typescript
session: ISessionService;
```

View file

@ -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>

View file

@ -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) | | |

View file

@ -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&lt;[CoreStart, any, unknown]&gt;</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> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SearchInterceptorDeps](./kibana-plugin-plugins-data-public.searchinterceptordeps.md) &gt; [session](./kibana-plugin-plugins-data-public.searchinterceptordeps.session.md)
## SearchInterceptorDeps.session property
<b>Signature:</b>
```typescript
session: ISessionService;
```

View file

@ -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. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) &gt; [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) &gt; [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;
```

View file

@ -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 = () =>

View 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';

View file

@ -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>> = {

View file

@ -23,3 +23,4 @@ export * from './expressions';
export * from './search_source';
export * from './tabify';
export * from './types';
export * from './session';

View 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';

View 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(),
};
}

View 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;
}

View file

@ -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;

View file

@ -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 });

View file

@ -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(),
};
}

View file

@ -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 });
});
});
});
});

View file

@ -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);

View file

@ -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),
};
}

View 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();
});
});
});

View 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);
}
}

View file

@ -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';

View file

@ -689,6 +689,7 @@ export class IndexPatternsService implements Plugin_3<void, IndexPatternsService
// @public (undocumented)
export interface ISearchOptions {
abortSignal?: AbortSignal;
sessionId?: string;
strategy?: string;
}

View file

@ -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)

View file

@ -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) {

View file

@ -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();

View file

@ -0,0 +1,9 @@
{
"id": "session_notifications",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["session_notifications"],
"server": false,
"ui": true,
"requiredPlugins": ["data", "navigation"]
}

View file

@ -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"
}
}

View file

@ -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();

View file

@ -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() {}
}

View file

@ -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;
}

View file

@ -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" }
]
}

View file

@ -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'));
});
}

View file

@ -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 () => {

View 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);
});
});
});
}

View file

@ -40,6 +40,7 @@ export class DataEnhancedPlugin
uiSettings: core.uiSettings,
startServices: core.getStartServices(),
usageCollector: data.search.usageCollector,
session: data.search.session,
});
data.__enhance({

View file

@ -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,
});
});

View file

@ -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);

View file

@ -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) => {