[Search] Integrate "Send to background" UI with session service (#83073)

This commit is contained in:
Anton Dosov 2020-12-01 14:01:46 +01:00 committed by GitHub
parent 39ceadd96d
commit 4cb44d9e33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 1910 additions and 578 deletions

View file

@ -17,6 +17,7 @@ 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 [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) |
| [session](./kibana-plugin-plugins-data-public.isearchsetup.session.md) | <code>ISessionService</code> | Current session management [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) |
| [sessionsClient](./kibana-plugin-plugins-data-public.isearchsetup.sessionsclient.md) | <code>ISessionsClient</code> | Background search sessions SO CRUD [ISessionsClient](./kibana-plugin-plugins-data-public.isessionsclient.md) |
| [usageCollector](./kibana-plugin-plugins-data-public.isearchsetup.usagecollector.md) | <code>SearchUsageCollector</code> | |

View file

@ -4,7 +4,7 @@
## ISearchSetup.session property
session management [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md)
Current session management [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md)
<b>Signature:</b>

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; [sessionsClient](./kibana-plugin-plugins-data-public.isearchsetup.sessionsclient.md)
## ISearchSetup.sessionsClient property
Background search sessions SO CRUD [ISessionsClient](./kibana-plugin-plugins-data-public.isessionsclient.md)
<b>Signature:</b>
```typescript
sessionsClient: ISessionsClient;
```

View file

@ -19,6 +19,7 @@ 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 [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) |
| [session](./kibana-plugin-plugins-data-public.isearchstart.session.md) | <code>ISessionService</code> | Current session management [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) |
| [sessionsClient](./kibana-plugin-plugins-data-public.isearchstart.sessionsclient.md) | <code>ISessionsClient</code> | Background search sessions SO CRUD [ISessionsClient](./kibana-plugin-plugins-data-public.isessionsclient.md) |
| [showError](./kibana-plugin-plugins-data-public.isearchstart.showerror.md) | <code>(e: Error) =&gt; void</code> | |

View file

@ -4,7 +4,7 @@
## ISearchStart.session property
session management [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md)
Current session management [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md)
<b>Signature:</b>

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; [sessionsClient](./kibana-plugin-plugins-data-public.isearchstart.sessionsclient.md)
## ISearchStart.sessionsClient property
Background search sessions SO CRUD [ISessionsClient](./kibana-plugin-plugins-data-public.isessionsclient.md)
<b>Signature:</b>
```typescript
sessionsClient: ISessionsClient;
```

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; [ISessionsClient](./kibana-plugin-plugins-data-public.isessionsclient.md)
## ISessionsClient type
<b>Signature:</b>
```typescript
export declare type ISessionsClient = PublicContract<SessionsClient>;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [clear](./kibana-plugin-plugins-data-public.isessionservice.clear.md)
## ISessionService.clear property
Clears the active session.
<b>Signature:</b>
```typescript
clear: () => void;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [delete](./kibana-plugin-plugins-data-public.isessionservice.delete.md)
## ISessionService.delete property
Deletes a session
<b>Signature:</b>
```typescript
delete: (sessionId: string) => Promise<void>;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [find](./kibana-plugin-plugins-data-public.isessionservice.find.md)
## ISessionService.find property
Gets a list of saved sessions
<b>Signature:</b>
```typescript
find: (options: SearchSessionFindOptions) => Promise<SavedObjectsFindResponse<BackgroundSessionSavedObjectAttributes>>;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [get](./kibana-plugin-plugins-data-public.isessionservice.get.md)
## ISessionService.get property
Gets a saved session
<b>Signature:</b>
```typescript
get: (sessionId: string) => Promise<SavedObject<BackgroundSessionSavedObjectAttributes>>;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [getSession$](./kibana-plugin-plugins-data-public.isessionservice.getsession_.md)
## ISessionService.getSession$ property
Returns the observable that emits an update every time the session ID changes
<b>Signature:</b>
```typescript
getSession$: () => Observable<string | undefined>;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [getSessionId](./kibana-plugin-plugins-data-public.isessionservice.getsessionid.md)
## ISessionService.getSessionId property
Returns the active session ID
<b>Signature:</b>
```typescript
getSessionId: () => string | undefined;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [isRestore](./kibana-plugin-plugins-data-public.isessionservice.isrestore.md)
## ISessionService.isRestore property
Whether the active session is restored (i.e. reusing previous search IDs)
<b>Signature:</b>
```typescript
isRestore: () => boolean;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [isStored](./kibana-plugin-plugins-data-public.isessionservice.isstored.md)
## ISessionService.isStored property
Whether the active session is already saved (i.e. sent to background)
<b>Signature:</b>
```typescript
isStored: () => boolean;
```

View file

@ -2,28 +2,10 @@
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md)
## ISessionService interface
## ISessionService type
<b>Signature:</b>
```typescript
export interface ISessionService
export declare type ISessionService = PublicContract<SessionService>;
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [clear](./kibana-plugin-plugins-data-public.isessionservice.clear.md) | <code>() =&gt; void</code> | Clears the active session. |
| [delete](./kibana-plugin-plugins-data-public.isessionservice.delete.md) | <code>(sessionId: string) =&gt; Promise&lt;void&gt;</code> | Deletes a session |
| [find](./kibana-plugin-plugins-data-public.isessionservice.find.md) | <code>(options: SearchSessionFindOptions) =&gt; Promise&lt;SavedObjectsFindResponse&lt;BackgroundSessionSavedObjectAttributes&gt;&gt;</code> | Gets a list of saved sessions |
| [get](./kibana-plugin-plugins-data-public.isessionservice.get.md) | <code>(sessionId: string) =&gt; Promise&lt;SavedObject&lt;BackgroundSessionSavedObjectAttributes&gt;&gt;</code> | Gets a saved session |
| [getSession$](./kibana-plugin-plugins-data-public.isessionservice.getsession_.md) | <code>() =&gt; Observable&lt;string &#124; undefined&gt;</code> | Returns the observable that emits an update every time the session ID changes |
| [getSessionId](./kibana-plugin-plugins-data-public.isessionservice.getsessionid.md) | <code>() =&gt; string &#124; undefined</code> | Returns the active session ID |
| [isRestore](./kibana-plugin-plugins-data-public.isessionservice.isrestore.md) | <code>() =&gt; boolean</code> | Whether the active session is restored (i.e. reusing previous search IDs) |
| [isStored](./kibana-plugin-plugins-data-public.isessionservice.isstored.md) | <code>() =&gt; boolean</code> | Whether the active session is already saved (i.e. sent to background) |
| [restore](./kibana-plugin-plugins-data-public.isessionservice.restore.md) | <code>(sessionId: string) =&gt; Promise&lt;SavedObject&lt;BackgroundSessionSavedObjectAttributes&gt;&gt;</code> | Restores existing session |
| [save](./kibana-plugin-plugins-data-public.isessionservice.save.md) | <code>(name: string, url: string) =&gt; Promise&lt;SavedObject&lt;BackgroundSessionSavedObjectAttributes&gt;&gt;</code> | Saves a session |
| [start](./kibana-plugin-plugins-data-public.isessionservice.start.md) | <code>() =&gt; string</code> | Starts a new session |
| [update](./kibana-plugin-plugins-data-public.isessionservice.update.md) | <code>(sessionId: string, attributes: Partial&lt;BackgroundSessionSavedObjectAttributes&gt;) =&gt; Promise&lt;any&gt;</code> | Updates a session |

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [restore](./kibana-plugin-plugins-data-public.isessionservice.restore.md)
## ISessionService.restore property
Restores existing session
<b>Signature:</b>
```typescript
restore: (sessionId: string) => Promise<SavedObject<BackgroundSessionSavedObjectAttributes>>;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [save](./kibana-plugin-plugins-data-public.isessionservice.save.md)
## ISessionService.save property
Saves a session
<b>Signature:</b>
```typescript
save: (name: string, url: string) => Promise<SavedObject<BackgroundSessionSavedObjectAttributes>>;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [start](./kibana-plugin-plugins-data-public.isessionservice.start.md)
## ISessionService.start property
Starts a new session
<b>Signature:</b>
```typescript
start: () => string;
```

View file

@ -1,13 +0,0 @@
<!-- 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; [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) &gt; [update](./kibana-plugin-plugins-data-public.isessionservice.update.md)
## ISessionService.update property
Updates a session
<b>Signature:</b>
```typescript
update: (sessionId: string, attributes: Partial<BackgroundSessionSavedObjectAttributes>) => Promise<any>;
```

View file

@ -34,6 +34,7 @@
| [KBN\_FIELD\_TYPES](./kibana-plugin-plugins-data-public.kbn_field_types.md) | \* |
| [METRIC\_TYPES](./kibana-plugin-plugins-data-public.metric_types.md) | |
| [QuerySuggestionTypes](./kibana-plugin-plugins-data-public.querysuggestiontypes.md) | |
| [SessionState](./kibana-plugin-plugins-data-public.sessionstate.md) | Possible state that current session can be in |
| [SortDirection](./kibana-plugin-plugins-data-public.sortdirection.md) | |
| [TimeoutErrorMode](./kibana-plugin-plugins-data-public.timeouterrormode.md) | |
@ -74,7 +75,6 @@
| [ISearchSetup](./kibana-plugin-plugins-data-public.isearchsetup.md) | The setup contract exposed by the Search plugin exposes the search strategy extension point. |
| [ISearchStart](./kibana-plugin-plugins-data-public.isearchstart.md) | search service |
| [ISearchStartSearchSource](./kibana-plugin-plugins-data-public.isearchstartsearchsource.md) | high level search service |
| [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) | |
| [KueryNode](./kibana-plugin-plugins-data-public.kuerynode.md) | |
| [OptionedValueProp](./kibana-plugin-plugins-data-public.optionedvalueprop.md) | |
| [QueryState](./kibana-plugin-plugins-data-public.querystate.md) | All query state service state |
@ -89,6 +89,7 @@
| [SavedQueryService](./kibana-plugin-plugins-data-public.savedqueryservice.md) | |
| [SearchError](./kibana-plugin-plugins-data-public.searcherror.md) | |
| [SearchInterceptorDeps](./kibana-plugin-plugins-data-public.searchinterceptordeps.md) | |
| [SearchSessionInfoProvider](./kibana-plugin-plugins-data-public.searchSessionInfoprovider.md) | Provide info about current search session to be stored in backgroundSearch saved object |
| [SearchSourceFields](./kibana-plugin-plugins-data-public.searchsourcefields.md) | search source fields |
| [TabbedAggColumn](./kibana-plugin-plugins-data-public.tabbedaggcolumn.md) | \* |
| [TabbedTable](./kibana-plugin-plugins-data-public.tabbedtable.md) | \* |
@ -166,6 +167,8 @@
| [InputTimeRange](./kibana-plugin-plugins-data-public.inputtimerange.md) | |
| [ISearchGeneric](./kibana-plugin-plugins-data-public.isearchgeneric.md) | |
| [ISearchSource](./kibana-plugin-plugins-data-public.isearchsource.md) | search source interface |
| [ISessionsClient](./kibana-plugin-plugins-data-public.isessionsclient.md) | |
| [ISessionService](./kibana-plugin-plugins-data-public.isessionservice.md) | |
| [KibanaContext](./kibana-plugin-plugins-data-public.kibanacontext.md) | |
| [MatchAllFilter](./kibana-plugin-plugins-data-public.matchallfilter.md) | |
| [ParsedInterval](./kibana-plugin-plugins-data-public.parsedinterval.md) | |

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; [SearchSessionInfoProvider](./kibana-plugin-plugins-data-public.searchSessionInfoprovider.md) &gt; [getName](./kibana-plugin-plugins-data-public.searchSessionInfoprovider.getname.md)
## SearchSessionInfoProvider.getName property
User-facing name of the session. e.g. will be displayed in background sessions management list
<b>Signature:</b>
```typescript
getName: () => Promise<string>;
```

View file

@ -0,0 +1,15 @@
<!-- 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; [SearchSessionInfoProvider](./kibana-plugin-plugins-data-public.searchSessionInfoprovider.md) &gt; [getUrlGeneratorData](./kibana-plugin-plugins-data-public.searchSessionInfoprovider.geturlgeneratordata.md)
## SearchSessionInfoProvider.getUrlGeneratorData property
<b>Signature:</b>
```typescript
getUrlGeneratorData: () => Promise<{
urlGeneratorId: ID;
initialState: UrlGeneratorStateMapping[ID]['State'];
restoreState: UrlGeneratorStateMapping[ID]['State'];
}>;
```

View file

@ -0,0 +1,21 @@
<!-- 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; [SearchSessionInfoProvider](./kibana-plugin-plugins-data-public.searchSessionInfoprovider.md)
## SearchSessionInfoProvider interface
Provide info about current search session to be stored in backgroundSearch saved object
<b>Signature:</b>
```typescript
export interface SearchSessionInfoProvider<ID extends UrlGeneratorId = UrlGeneratorId>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [getName](./kibana-plugin-plugins-data-public.searchSessionInfoprovider.getname.md) | <code>() =&gt; Promise&lt;string&gt;</code> | User-facing name of the session. e.g. will be displayed in background sessions management list |
| [getUrlGeneratorData](./kibana-plugin-plugins-data-public.searchSessionInfoprovider.geturlgeneratordata.md) | <code>() =&gt; Promise&lt;{</code><br/><code> urlGeneratorId: ID;</code><br/><code> initialState: UrlGeneratorStateMapping[ID]['State'];</code><br/><code> restoreState: UrlGeneratorStateMapping[ID]['State'];</code><br/><code> }&gt;</code> | |

View file

@ -0,0 +1,26 @@
<!-- 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; [SessionState](./kibana-plugin-plugins-data-public.sessionstate.md)
## SessionState enum
Possible state that current session can be in
<b>Signature:</b>
```typescript
export declare enum SessionState
```
## Enumeration Members
| Member | Value | Description |
| --- | --- | --- |
| BackgroundCompleted | <code>&quot;backgroundCompleted&quot;</code> | Page load completed with background session created. |
| BackgroundLoading | <code>&quot;backgroundLoading&quot;</code> | Search request was sent to the background. The page is loading in background. |
| Canceled | <code>&quot;canceled&quot;</code> | Current session requests where explicitly canceled by user Displaying none or partial results |
| Completed | <code>&quot;completed&quot;</code> | No action was taken and the page completed loading without background session creation. |
| Loading | <code>&quot;loading&quot;</code> | Pending search request has not been sent to the background yet |
| None | <code>&quot;none&quot;</code> | Session is not active, e.g. didn't start |
| Restored | <code>&quot;restored&quot;</code> | Revisiting the page after background completion |

View file

@ -74,7 +74,7 @@ import { NavAction, SavedDashboardPanel } from '../types';
import { showOptionsPopover } from './top_nav/show_options_popover';
import { DashboardSaveModal, SaveOptions } from './top_nav/save_modal';
import { showCloneModal } from './top_nav/show_clone_modal';
import { saveDashboard } from './lib';
import { createSessionRestorationDataProvider, saveDashboard } from './lib';
import { DashboardStateManager } from './dashboard_state_manager';
import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants';
import { getTopNavConfig } from './top_nav/get_top_nav_config';
@ -150,7 +150,7 @@ export class DashboardAppController {
dashboardCapabilities,
scopedHistory,
embeddableCapabilities: { visualizeCapabilities, mapsCapabilities },
data: { query: queryService, search: searchService },
data,
core: {
notifications,
overlays,
@ -168,6 +168,8 @@ export class DashboardAppController {
navigation,
savedObjectsTagging,
}: DashboardAppControllerDependencies) {
const queryService = data.query;
const searchService = data.search;
const filterManager = queryService.filterManager;
const timefilter = queryService.timefilter.timefilter;
const queryStringManager = queryService.queryString;
@ -262,6 +264,16 @@ export class DashboardAppController {
$scope.showSaveQuery = dashboardCapabilities.saveQuery as boolean;
const landingPageUrl = () => `#${DashboardConstants.LANDING_PAGE_PATH}`;
const getDashTitle = () =>
getDashboardTitle(
dashboardStateManager.getTitle(),
dashboardStateManager.getViewMode(),
dashboardStateManager.getIsDirty(timefilter),
dashboardStateManager.isNew()
);
const getShouldShowEditHelp = () =>
!dashboardStateManager.getPanels().length &&
dashboardStateManager.getIsEditMode() &&
@ -429,6 +441,15 @@ export class DashboardAppController {
DashboardContainer
>(DASHBOARD_CONTAINER_TYPE);
searchService.session.setSearchSessionInfoProvider(
createSessionRestorationDataProvider({
data,
getDashboardTitle: () => getDashTitle(),
getDashboardId: () => dash.id,
getAppState: () => dashboardStateManager.getAppState(),
})
);
if (dashboardFactory) {
const searchSessionIdFromURL = getSearchSessionIdFromURL(history);
if (searchSessionIdFromURL) {
@ -552,16 +573,6 @@ export class DashboardAppController {
filterManager.getFilters()
);
const landingPageUrl = () => `#${DashboardConstants.LANDING_PAGE_PATH}`;
const getDashTitle = () =>
getDashboardTitle(
dashboardStateManager.getTitle(),
dashboardStateManager.getViewMode(),
dashboardStateManager.getIsDirty(timefilter),
dashboardStateManager.isNew()
);
// Push breadcrumbs to new header navigation
const updateBreadcrumbs = () => {
chrome.setBreadcrumbs([
@ -638,6 +649,13 @@ export class DashboardAppController {
}
};
const searchServiceSessionRefreshSubscribtion = searchService.session.onRefresh$.subscribe(
() => {
lastReloadRequestTime = new Date().getTime();
refreshDashboardContainer();
}
);
const updateStateFromSavedQuery = (savedQuery: SavedQuery) => {
const allFilters = filterManager.getFilters();
dashboardStateManager.applyFilters(savedQuery.attributes.query, allFilters);
@ -1199,6 +1217,7 @@ export class DashboardAppController {
if (dashboardContainer) {
dashboardContainer.destroy();
}
searchServiceSessionRefreshSubscribtion.unsubscribe();
searchService.session.clear();
});
}

View file

@ -21,3 +21,4 @@ export { saveDashboard } from './save_dashboard';
export { getAppStateDefaults } from './get_app_state_defaults';
export { migrateAppState } from './migrate_app_state';
export { getDashboardIdFromUrl } from './url';
export { createSessionRestorationDataProvider } from './session_restoration';

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 { DASHBOARD_APP_URL_GENERATOR, DashboardUrlGeneratorState } from '../../url_generator';
import { DataPublicPluginStart } from '../../../../data/public';
import { DashboardAppState } from '../../types';
export function createSessionRestorationDataProvider(deps: {
data: DataPublicPluginStart;
getAppState: () => DashboardAppState;
getDashboardTitle: () => string;
getDashboardId: () => string;
}) {
return {
getName: async () => deps.getDashboardTitle(),
getUrlGeneratorData: async () => {
return {
urlGeneratorId: DASHBOARD_APP_URL_GENERATOR,
initialState: getUrlGeneratorState({ ...deps, forceAbsoluteTime: false }),
restoreState: getUrlGeneratorState({ ...deps, forceAbsoluteTime: true }),
};
},
};
}
function getUrlGeneratorState({
data,
getAppState,
getDashboardId,
forceAbsoluteTime, // TODO: not implemented
}: {
data: DataPublicPluginStart;
getAppState: () => DashboardAppState;
getDashboardId: () => string;
forceAbsoluteTime: boolean;
}): DashboardUrlGeneratorState {
const appState = getAppState();
return {
dashboardId: getDashboardId(),
timeRange: data.query.timefilter.timefilter.getTime(),
filters: data.query.filterManager.getFilters(),
query: data.query.queryString.formatQuery(appState.query),
savedQuery: appState.savedQuery,
useHash: false,
preserveSavedFilters: false,
viewMode: appState.viewMode,
panels: getDashboardId() ? undefined : appState.panels,
searchSessionId: data.search.session.getSessionId(),
};
}

View file

@ -142,6 +142,39 @@ describe('dashboard url generator', () => {
);
});
test('savedQuery', async () => {
const generator = createDashboardUrlGenerator(() =>
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader(),
})
);
const url = await generator.createUrl!({
savedQuery: '__savedQueryId__',
});
expect(url).toMatchInlineSnapshot(
`"xyz/app/dashboards#/create?_a=(savedQuery:__savedQueryId__)&_g=()"`
);
expect(url).toContain('__savedQueryId__');
});
test('panels', async () => {
const generator = createDashboardUrlGenerator(() =>
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader(),
})
);
const url = await generator.createUrl!({
panels: [{ fakePanelContent: 'fakePanelContent' } as any],
});
expect(url).toMatchInlineSnapshot(
`"xyz/app/dashboards#/create?_a=(panels:!((fakePanelContent:fakePanelContent)))&_g=()"`
);
});
test('if no useHash setting is given, uses the one was start services', async () => {
const generator = createDashboardUrlGenerator(() =>
Promise.resolve({

View file

@ -30,6 +30,7 @@ import { UrlGeneratorsDefinition } from '../../share/public';
import { SavedObjectLoader } from '../../saved_objects/public';
import { ViewMode } from '../../embeddable/public';
import { DashboardConstants } from './dashboard_constants';
import { SavedDashboardPanel } from '../common/types';
export const STATE_STORAGE_KEY = '_a';
export const GLOBAL_STATE_STORAGE_KEY = '_g';
@ -86,6 +87,16 @@ export interface DashboardUrlGeneratorState {
* (Background search)
*/
searchSessionId?: string;
/**
* List of dashboard panels
*/
panels?: SavedDashboardPanel[];
/**
* Saved query ID
*/
savedQuery?: string;
}
export const createDashboardUrlGenerator = (
@ -137,6 +148,8 @@ export const createDashboardUrlGenerator = (
query: state.query,
filters: filters?.filter((f) => !esFilters.isFilterPinned(f)),
viewMode: state.viewMode,
panels: state.panels,
savedQuery: state.savedQuery,
}),
{ useHash },
`${appBasePath}#/${hash}`

View file

@ -55,7 +55,8 @@ export interface AggTypeConfig<
aggConfig: TAggConfig,
searchSource: ISearchSource,
inspectorRequestAdapter?: RequestAdapter,
abortSignal?: AbortSignal
abortSignal?: AbortSignal,
searchSessionId?: string
) => Promise<any>;
getSerializedFormat?: (agg: TAggConfig) => SerializedFieldFormat;
getValue?: (agg: TAggConfig, bucket: any) => any;
@ -182,6 +183,8 @@ export class AggType<
* @param searchSourceAggs - SearchSource aggregation configuration
* @param resp - Response to the main request
* @param nestedSearchSource - the new SearchSource that will be used to make post flight request
* @param abortSignal - `AbortSignal` to abort the request
* @param searchSessionId - searchSessionId to be used for grouping requests into a single search session
* @return {Promise}
*/
postFlightRequest: (
@ -190,7 +193,8 @@ export class AggType<
aggConfig: TAggConfig,
searchSource: ISearchSource,
inspectorRequestAdapter?: RequestAdapter,
abortSignal?: AbortSignal
abortSignal?: AbortSignal,
searchSessionId?: string
) => Promise<any>;
/**
* Get the serialized format for the values produced by this agg type,

View file

@ -102,7 +102,8 @@ export const getTermsBucketAgg = () =>
aggConfig,
searchSource,
inspectorRequestAdapter,
abortSignal
abortSignal,
searchSessionId
) => {
if (!resp.aggregations) return resp;
const nestedSearchSource = searchSource.createChild();
@ -124,6 +125,7 @@ export const getTermsBucketAgg = () =>
'This request counts the number of documents that fall ' +
'outside the criterion of the data buckets.',
}),
searchSessionId,
}
);
nestedSearchSource.getSearchRequestBody().then((body) => {
@ -132,7 +134,10 @@ export const getTermsBucketAgg = () =>
request.stats(getRequestInspectorStats(nestedSearchSource));
}
const response = await nestedSearchSource.fetch({ abortSignal });
const response = await nestedSearchSource.fetch({
abortSignal,
sessionId: searchSessionId,
});
if (request) {
request
.stats(getResponseInspectorStats(response, nestedSearchSource))

View file

@ -17,82 +17,19 @@
* under the License.
*/
import { Observable } from 'rxjs';
import type { SavedObject, SavedObjectsFindResponse } from 'kibana/server';
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>;
/**
* Whether the active session is already saved (i.e. sent to background)
*/
isStored: () => boolean;
/**
* Whether the active session is restored (i.e. reusing previous search IDs)
*/
isRestore: () => boolean;
/**
* Starts a new session
*/
start: () => string;
/**
* Restores existing session
*/
restore: (sessionId: string) => Promise<SavedObject<BackgroundSessionSavedObjectAttributes>>;
/**
* Clears the active session.
*/
clear: () => void;
/**
* Saves a session
*/
save: (name: string, url: string) => Promise<SavedObject<BackgroundSessionSavedObjectAttributes>>;
/**
* Gets a saved session
*/
get: (sessionId: string) => Promise<SavedObject<BackgroundSessionSavedObjectAttributes>>;
/**
* Gets a list of saved sessions
*/
find: (
options: SearchSessionFindOptions
) => Promise<SavedObjectsFindResponse<BackgroundSessionSavedObjectAttributes>>;
/**
* Updates a session
*/
update: (
sessionId: string,
attributes: Partial<BackgroundSessionSavedObjectAttributes>
) => Promise<any>;
/**
* Deletes a session
*/
delete: (sessionId: string) => Promise<void>;
}
export interface BackgroundSessionSavedObjectAttributes {
/**
* User-facing session name to be displayed in session management
*/
name: string;
/**
* App that created the session. e.g 'discover'
*/
appId: string;
created: string;
expires: string;
status: string;
urlGeneratorId: string;
initialState: Record<string, unknown>;
restoreState: Record<string, unknown>;
idMapping: Record<string, string>;

View file

@ -6,7 +6,8 @@
"requiredPlugins": [
"bfetch",
"expressions",
"uiActions"
"uiActions",
"share"
],
"optionalPlugins": ["usageCollection"],
"extraPublicDirs": ["common"],

View file

@ -385,6 +385,7 @@ export {
SearchRequest,
SearchSourceFields,
SortDirection,
SessionState,
// expression functions and types
EsdslExpressionFunctionDefinition,
EsRawResponseExpressionTypeDefinition,
@ -395,7 +396,12 @@ export {
PainlessError,
} from './search';
export type { SearchSource, ISessionService } from './search';
export type {
SearchSource,
ISessionService,
SearchSessionInfoProvider,
ISessionsClient,
} from './search';
export { ISearchOptions, isErrorResponse, isCompleteResponse, isPartialResponse } from '../common';

View file

@ -40,6 +40,7 @@ import { ExpressionValueBoxed } from 'src/plugins/expressions/common';
import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_formats/utils';
import { History } from 'history';
import { Href } from 'history';
import { HttpSetup } from 'kibana/public';
import { IconType } from '@elastic/eui';
import { InjectedIntl } from '@kbn/i18n/react';
import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public';
@ -62,7 +63,9 @@ import { PackageInfo } from '@kbn/config';
import { Path } from 'history';
import { Plugin as Plugin_2 } from 'src/core/public';
import { PluginInitializerContext as PluginInitializerContext_2 } from 'src/core/public';
import { PluginInitializerContext as PluginInitializerContext_3 } from 'kibana/public';
import { PopoverAnchorPosition } from '@elastic/eui';
import { PublicContract } from '@kbn/utility-types';
import { PublicMethodsOf } from '@kbn/utility-types';
import { PublicUiSettingsParams } from 'src/core/server/types';
import React from 'react';
@ -82,6 +85,7 @@ import { SavedObjectsFindResponse } from 'kibana/server';
import { Search } from '@elastic/elasticsearch/api/requestParams';
import { SearchResponse } from 'elasticsearch';
import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/expressions/common';
import { StartServicesAccessor } from 'kibana/public';
import { ToastInputFields } from 'src/core/public/notifications';
import { ToastsSetup } from 'kibana/public';
import { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport';
@ -1478,6 +1482,7 @@ export interface ISearchSetup {
// (undocumented)
aggs: AggsSetup;
session: ISessionService;
sessionsClient: ISessionsClient;
// Warning: (ae-forgotten-export) The symbol "SearchUsageCollector" needs to be exported by the entry point index.d.ts
//
// (undocumented)
@ -1493,6 +1498,7 @@ export interface ISearchStart {
search: ISearchGeneric;
searchSource: ISearchStartSearchSource;
session: ISessionService;
sessionsClient: ISessionsClient;
// (undocumented)
showError: (e: Error) => void;
}
@ -1508,25 +1514,17 @@ export interface ISearchStartSearchSource {
// @public (undocumented)
export const isErrorResponse: (response?: IKibanaSearchResponse<any> | undefined) => boolean | undefined;
// Warning: (ae-forgotten-export) The symbol "SessionsClient" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "ISessionsClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type ISessionsClient = PublicContract<SessionsClient>;
// Warning: (ae-forgotten-export) The symbol "SessionService" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "ISessionService" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface ISessionService {
clear: () => void;
delete: (sessionId: string) => Promise<void>;
// Warning: (ae-forgotten-export) The symbol "SearchSessionFindOptions" needs to be exported by the entry point index.d.ts
find: (options: SearchSessionFindOptions) => Promise<SavedObjectsFindResponse<BackgroundSessionSavedObjectAttributes>>;
get: (sessionId: string) => Promise<SavedObject<BackgroundSessionSavedObjectAttributes>>;
getSession$: () => Observable<string | undefined>;
getSessionId: () => string | undefined;
isRestore: () => boolean;
isStored: () => boolean;
// Warning: (ae-forgotten-export) The symbol "BackgroundSessionSavedObjectAttributes" needs to be exported by the entry point index.d.ts
restore: (sessionId: string) => Promise<SavedObject<BackgroundSessionSavedObjectAttributes>>;
save: (name: string, url: string) => Promise<SavedObject<BackgroundSessionSavedObjectAttributes>>;
start: () => string;
update: (sessionId: string, attributes: Partial<BackgroundSessionSavedObjectAttributes>) => Promise<any>;
}
export type ISessionService = PublicContract<SessionService>;
// Warning: (ae-missing-release-tag) "isFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@ -2107,6 +2105,7 @@ export class SearchInterceptor {
timeoutSignal: AbortSignal;
combinedSignal: AbortSignal;
cleanup: () => void;
abort: () => void;
};
// (undocumented)
showError(e: Error): void;
@ -2135,6 +2134,20 @@ export interface SearchInterceptorDeps {
// @internal
export type SearchRequest = Record<string, any>;
// Warning: (ae-forgotten-export) The symbol "UrlGeneratorId" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "SearchSessionInfoProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface SearchSessionInfoProvider<ID extends UrlGeneratorId = UrlGeneratorId> {
getName: () => Promise<string>;
// (undocumented)
getUrlGeneratorData: () => Promise<{
urlGeneratorId: ID;
initialState: UrlGeneratorStateMapping[ID]['State'];
restoreState: UrlGeneratorStateMapping[ID]['State'];
}>;
}
// @public (undocumented)
export class SearchSource {
// Warning: (ae-forgotten-export) The symbol "SearchSourceDependencies" needs to be exported by the entry point index.d.ts
@ -2240,6 +2253,17 @@ export class SearchTimeoutError extends KbnError {
mode: TimeoutErrorMode;
}
// @public
export enum SessionState {
BackgroundCompleted = "backgroundCompleted",
BackgroundLoading = "backgroundLoading",
Canceled = "canceled",
Completed = "completed",
Loading = "loading",
None = "none",
Restored = "restored"
}
// Warning: (ae-missing-release-tag) "SortDirection" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@ -2415,22 +2439,23 @@ export const UI_SETTINGS: {
// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:415:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:426:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:430:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:428:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:429:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:432:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:433:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:436:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:45:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/search/session/session_service.ts:46:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)

View file

@ -182,7 +182,8 @@ export const handleRequest = async ({
agg,
requestSearchSource,
inspectorAdapters.requests,
abortSignal
abortSignal,
searchSessionId
);
}
}

View file

@ -40,9 +40,15 @@ export {
SearchSourceDependencies,
SearchSourceFields,
SortDirection,
ISessionService,
} from '../../common/search';
export {
SessionService,
ISessionService,
SearchSessionInfoProvider,
SessionState,
SessionsClient,
ISessionsClient,
} from './session';
export { getEsPreference } from './es_search';
export { SearchInterceptor, SearchInterceptorDeps } from './search_interceptor';

View file

@ -20,13 +20,14 @@
import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks';
import { searchSourceMock } from './search_source/mocks';
import { ISearchSetup, ISearchStart } from './types';
import { getSessionServiceMock } from '../../common/mocks';
import { getSessionsClientMock, getSessionServiceMock } from './session/mocks';
function createSetupContract(): jest.Mocked<ISearchSetup> {
return {
aggs: searchAggsSetupMock(),
__enhance: jest.fn(),
session: getSessionServiceMock(),
sessionsClient: getSessionsClientMock(),
};
}
@ -36,6 +37,7 @@ function createStartContract(): jest.Mocked<ISearchStart> {
search: jest.fn(),
showError: jest.fn(),
session: getSessionServiceMock(),
sessionsClient: getSessionsClientMock(),
searchSource: searchSourceMock.createStartContract(),
};
}

View file

@ -24,7 +24,7 @@ import { SearchInterceptor } from './search_interceptor';
import { AbortError } from '../../../kibana_utils/public';
import { SearchTimeoutError, PainlessError, TimeoutErrorMode } from './errors';
import { searchServiceMock } from './mocks';
import { ISearchStart } from '.';
import { ISearchStart, ISessionService } from '.';
import { bfetchPluginMock } from '../../../bfetch/public/mocks';
import { BfetchPublicSetup } from 'src/plugins/bfetch/public';
@ -104,7 +104,99 @@ describe('SearchInterceptor', () => {
params: {},
};
const response = searchInterceptor.search(mockRequest);
expect(response.toPromise()).resolves.toBe(mockResponse);
await expect(response.toPromise()).resolves.toBe(mockResponse);
});
describe('Search session', () => {
const setup = ({
isRestore = false,
isStored = false,
sessionId,
}: {
isRestore?: boolean;
isStored?: boolean;
sessionId?: string;
}) => {
const sessionServiceMock = searchMock.session as jest.Mocked<ISessionService>;
sessionServiceMock.getSessionId.mockImplementation(() => sessionId);
sessionServiceMock.isRestore.mockImplementation(() => isRestore);
sessionServiceMock.isStored.mockImplementation(() => isStored);
fetchMock.mockResolvedValue({ result: 200 });
};
const mockRequest: IEsSearchRequest = {
params: {},
};
afterEach(() => {
const sessionServiceMock = searchMock.session as jest.Mocked<ISessionService>;
sessionServiceMock.getSessionId.mockReset();
sessionServiceMock.isRestore.mockReset();
sessionServiceMock.isStored.mockReset();
fetchMock.mockReset();
});
test('infers isRestore from session service state', async () => {
const sessionId = 'sid';
setup({
isRestore: true,
sessionId,
});
await searchInterceptor.search(mockRequest, { sessionId }).toPromise();
expect(fetchMock.mock.calls[0][0]).toEqual(
expect.objectContaining({
options: { sessionId: 'sid', isStored: false, isRestore: true },
})
);
});
test('infers isStored from session service state', async () => {
const sessionId = 'sid';
setup({
isStored: true,
sessionId,
});
await searchInterceptor.search(mockRequest, { sessionId }).toPromise();
expect(fetchMock.mock.calls[0][0]).toEqual(
expect.objectContaining({
options: { sessionId: 'sid', isStored: true, isRestore: false },
})
);
});
test('skips isRestore & isStore in case not a current session Id', async () => {
setup({
isStored: true,
isRestore: true,
sessionId: 'session id',
});
await searchInterceptor
.search(mockRequest, { sessionId: 'different session id' })
.toPromise();
expect(fetchMock.mock.calls[0][0]).toEqual(
expect.objectContaining({
options: { sessionId: 'different session id', isStored: false, isRestore: false },
})
);
});
test('skips isRestore & isStore in case no session Id', async () => {
setup({
isStored: true,
isRestore: true,
sessionId: undefined,
});
await searchInterceptor.search(mockRequest, { sessionId: 'sessionId' }).toPromise();
expect(fetchMock.mock.calls[0][0]).toEqual(
expect.objectContaining({
options: { sessionId: 'sessionId', isStored: false, isRestore: false },
})
);
});
});
describe('Should throw typed errors', () => {

View file

@ -24,12 +24,7 @@ import { PublicMethodsOf } from '@kbn/utility-types';
import { CoreStart, CoreSetup, ToastsSetup } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { BatchedFunc, BfetchPublicSetup } from 'src/plugins/bfetch/public';
import {
IKibanaSearchRequest,
IKibanaSearchResponse,
ISearchOptions,
ISessionService,
} from '../../common';
import { IKibanaSearchRequest, IKibanaSearchResponse, ISearchOptions } from '../../common';
import { SearchUsageCollector } from './collectors';
import {
SearchTimeoutError,
@ -42,6 +37,7 @@ import {
} from './errors';
import { toMountPoint } from '../../../kibana_react/public';
import { AbortError, getCombinedAbortSignal } from '../../../kibana_utils/public';
import { ISessionService } from './session';
export interface SearchInterceptorDeps {
bfetch: BfetchPublicSetup;
@ -133,10 +129,18 @@ export class SearchInterceptor {
options?: ISearchOptions
): Promise<IKibanaSearchResponse> {
const { abortSignal, ...requestOptions } = options || {};
const isCurrentSession =
options?.sessionId && this.deps.session.getSessionId() === options.sessionId;
return this.batchedFetch(
{
request,
options: requestOptions,
options: {
...requestOptions,
isStored: isCurrentSession ? this.deps.session.isStored() : false,
isRestore: isCurrentSession ? this.deps.session.isRestore() : false,
},
},
abortSignal
);
@ -160,13 +164,18 @@ export class SearchInterceptor {
timeoutController.abort();
});
const selfAbortController = new AbortController();
// Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs:
// 1. The user manually aborts (via `cancelPending`)
// 2. The request times out
// 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines)
// 3. abort() is called on `selfAbortController`. This is used by session service to abort all pending searches that it tracks
// in the current session
// 4. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines)
const signals = [
this.abortController.signal,
timeoutSignal,
selfAbortController.signal,
...(abortSignal ? [abortSignal] : []),
];
@ -184,6 +193,9 @@ export class SearchInterceptor {
timeoutSignal,
combinedSignal,
cleanup,
abort: () => {
selfAbortController.abort();
},
};
}

View file

@ -49,6 +49,8 @@ describe('Search service', () => {
expect(setup).toHaveProperty('aggs');
expect(setup).toHaveProperty('usageCollector');
expect(setup).toHaveProperty('__enhance');
expect(setup).toHaveProperty('sessionsClient');
expect(setup).toHaveProperty('session');
});
});
@ -61,6 +63,8 @@ describe('Search service', () => {
expect(start).toHaveProperty('aggs');
expect(start).toHaveProperty('search');
expect(start).toHaveProperty('searchSource');
expect(start).toHaveProperty('sessionsClient');
expect(start).toHaveProperty('session');
});
});
});

View file

@ -28,7 +28,6 @@ import {
kibanaContext,
kibanaContextFunction,
ISearchGeneric,
ISessionService,
SearchSourceDependencies,
SearchSourceService,
} from '../../common/search';
@ -40,7 +39,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 { ISessionsClient, ISessionService, SessionsClient, SessionService } from './session';
import { ConfigSchema } from '../../config';
import {
SHARD_DELAY_AGG_NAME,
@ -67,6 +66,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
private searchInterceptor!: ISearchInterceptor;
private usageCollector?: SearchUsageCollector;
private sessionService!: ISessionService;
private sessionsClient!: ISessionsClient;
constructor(private initializerContext: PluginInitializerContext<ConfigSchema>) {}
@ -76,7 +76,12 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
): ISearchSetup {
this.usageCollector = createUsageCollector(getStartServices, usageCollection);
this.sessionService = new SessionService(this.initializerContext, getStartServices);
this.sessionsClient = new SessionsClient({ http });
this.sessionService = new SessionService(
this.initializerContext,
getStartServices,
this.sessionsClient
);
/**
* 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.
@ -115,6 +120,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
this.searchInterceptor = enhancements.searchInterceptor;
},
session: this.sessionService,
sessionsClient: this.sessionsClient,
};
}
@ -146,6 +152,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
this.searchInterceptor.showError(e);
},
session: this.sessionService,
sessionsClient: this.sessionsClient,
searchSource: this.searchSourceService.start(indexPatterns, searchSourceDependencies),
};
}

View file

@ -17,4 +17,6 @@
* under the License.
*/
export { getSessionServiceMock } from './search/session/mocks';
export { SessionService, ISessionService, SearchSessionInfoProvider } from './session_service';
export { SessionState } from './session_state';
export { SessionsClient, ISessionsClient } from './sessions_client';

View file

@ -17,8 +17,20 @@
* under the License.
*/
import { BehaviorSubject } from 'rxjs';
import { ISessionService } from './types';
import { BehaviorSubject, Subject } from 'rxjs';
import { ISessionsClient } from './sessions_client';
import { ISessionService } from './session_service';
import { SessionState } from './session_state';
export function getSessionsClientMock(): jest.Mocked<ISessionsClient> {
return {
get: jest.fn(),
create: jest.fn(),
find: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
}
export function getSessionServiceMock(): jest.Mocked<ISessionService> {
return {
@ -27,12 +39,15 @@ export function getSessionServiceMock(): jest.Mocked<ISessionService> {
restore: jest.fn(),
getSessionId: jest.fn(),
getSession$: jest.fn(() => new BehaviorSubject(undefined).asObservable()),
state$: new BehaviorSubject<SessionState>(SessionState.None).asObservable(),
setSearchSessionInfoProvider: jest.fn(),
trackSearch: jest.fn((searchDescriptor) => () => {}),
destroy: jest.fn(),
onRefresh$: new Subject(),
refresh: jest.fn(),
cancel: jest.fn(),
isStored: jest.fn(),
isRestore: jest.fn(),
save: jest.fn(),
get: jest.fn(),
find: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
}

View file

@ -17,20 +17,27 @@
* under the License.
*/
import { SessionService } from './session_service';
import { ISessionService } from '../../common';
import { coreMock } from '../../../../core/public/mocks';
import { SessionService, ISessionService } from './session_service';
import { coreMock } from '../../../../../core/public/mocks';
import { take, toArray } from 'rxjs/operators';
import { getSessionsClientMock } from './mocks';
import { BehaviorSubject } from 'rxjs';
import { SessionState } from './session_state';
describe('Session service', () => {
let sessionService: ISessionService;
let state$: BehaviorSubject<SessionState>;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext();
sessionService = new SessionService(
initializerContext,
coreMock.createSetup().getStartServices
coreMock.createSetup().getStartServices,
getSessionsClientMock(),
{ freezeState: false } // needed to use mocks inside state container
);
state$ = new BehaviorSubject<SessionState>(SessionState.None);
sessionService.state$.subscribe(state$);
});
describe('Session management', () => {
@ -55,5 +62,35 @@ describe('Session service', () => {
expect(await emittedValues).toEqual(['1', '2', undefined]);
});
it('Tracks searches for current session', () => {
expect(() => sessionService.trackSearch({ abort: () => {} })).toThrowError();
expect(state$.getValue()).toBe(SessionState.None);
sessionService.start();
const untrack1 = sessionService.trackSearch({ abort: () => {} });
expect(state$.getValue()).toBe(SessionState.Loading);
const untrack2 = sessionService.trackSearch({ abort: () => {} });
expect(state$.getValue()).toBe(SessionState.Loading);
untrack1();
expect(state$.getValue()).toBe(SessionState.Loading);
untrack2();
expect(state$.getValue()).toBe(SessionState.Completed);
});
it('Cancels all tracked searches within current session', async () => {
const abort = jest.fn();
sessionService.start();
sessionService.trackSearch({ abort });
sessionService.trackSearch({ abort });
sessionService.trackSearch({ abort });
const untrack = sessionService.trackSearch({ abort });
untrack();
await sessionService.cancel();
expect(abort).toBeCalledTimes(3);
});
});
});

View file

@ -0,0 +1,242 @@
/*
* 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 { PublicContract } from '@kbn/utility-types';
import { distinctUntilChanged, map, startWith } from 'rxjs/operators';
import { Observable, Subject, Subscription } from 'rxjs';
import { PluginInitializerContext, StartServicesAccessor } from 'kibana/public';
import { UrlGeneratorId, UrlGeneratorStateMapping } from '../../../../share/public/';
import { ConfigSchema } from '../../../config';
import { createSessionStateContainer, SessionState, SessionStateContainer } from './session_state';
import { ISessionsClient } from './sessions_client';
export type ISessionService = PublicContract<SessionService>;
export interface TrackSearchDescriptor {
abort: () => void;
}
/**
* Provide info about current search session to be stored in backgroundSearch saved object
*/
export interface SearchSessionInfoProvider<ID extends UrlGeneratorId = UrlGeneratorId> {
/**
* User-facing name of the session.
* e.g. will be displayed in background sessions management list
*/
getName: () => Promise<string>;
getUrlGeneratorData: () => Promise<{
urlGeneratorId: ID;
initialState: UrlGeneratorStateMapping[ID]['State'];
restoreState: UrlGeneratorStateMapping[ID]['State'];
}>;
}
/**
* Responsible for tracking a current search session. Supports only a single session at a time.
*/
export class SessionService {
public readonly state$: Observable<SessionState>;
private readonly state: SessionStateContainer<TrackSearchDescriptor>;
private searchSessionInfoProvider?: SearchSessionInfoProvider;
private appChangeSubscription$?: Subscription;
private curApp?: string;
constructor(
initializerContext: PluginInitializerContext<ConfigSchema>,
getStartServices: StartServicesAccessor,
private readonly sessionsClient: ISessionsClient,
{ freezeState = true }: { freezeState: boolean } = { freezeState: true }
) {
const { stateContainer, sessionState$ } = createSessionStateContainer<TrackSearchDescriptor>({
freeze: freezeState,
});
this.state$ = sessionState$;
this.state = stateContainer;
getStartServices().then(([coreStart]) => {
// Apps required to clean up their sessions before unmounting
// Make sure that apps don't leave sessions open.
this.appChangeSubscription$ = coreStart.application.currentAppId$.subscribe((appName) => {
if (this.state.get().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.clear();
}
}
this.curApp = appName;
});
});
}
/**
* Set a provider of info about current session
* This will be used for creating a background session saved object
* @param searchSessionInfoProvider
*/
public setSearchSessionInfoProvider<ID extends UrlGeneratorId = UrlGeneratorId>(
searchSessionInfoProvider: SearchSessionInfoProvider<ID> | undefined
) {
this.searchSessionInfoProvider = searchSessionInfoProvider;
}
/**
* Used to track pending searches within current session
*
* @param searchDescriptor - uniq object that will be used to untrack the search
* @returns untrack function
*/
public trackSearch(searchDescriptor: TrackSearchDescriptor): () => void {
this.state.transitions.trackSearch(searchDescriptor);
return () => {
this.state.transitions.unTrackSearch(searchDescriptor);
};
}
public destroy() {
if (this.appChangeSubscription$) {
this.appChangeSubscription$.unsubscribe();
}
this.clear();
}
/**
* Get current session id
*/
public getSessionId() {
return this.state.get().sessionId;
}
/**
* Get observable for current session id
*/
public getSession$() {
return this.state.state$.pipe(
startWith(this.state.get()),
map((s) => s.sessionId),
distinctUntilChanged()
);
}
/**
* Is current session already saved as SO (send to background)
*/
public isStored() {
return this.state.get().isStored;
}
/**
* Is restoring the older saved searches
*/
public isRestore() {
return this.state.get().isRestore;
}
/**
* Start a new search session
* @returns sessionId
*/
public start() {
this.state.transitions.start();
return this.getSessionId()!;
}
/**
* Restore previously saved search session
* @param sessionId
*/
public restore(sessionId: string) {
this.state.transitions.restore(sessionId);
}
/**
* Cleans up current state
*/
public clear() {
this.state.transitions.clear();
this.setSearchSessionInfoProvider(undefined);
}
private refresh$ = new Subject<void>();
/**
* Observable emits when search result refresh was requested
* For example, search to background UI could have it's own "refresh" button
* Application would use this observable to handle user interaction on that button
*/
public onRefresh$ = this.refresh$.asObservable();
/**
* Request a search results refresh
*/
public refresh() {
this.refresh$.next();
}
/**
* Request a cancellation of on-going search requests within current session
*/
public async cancel(): Promise<void> {
const isStoredSession = this.state.get().isStored;
this.state.get().pendingSearches.forEach((s) => {
s.abort();
});
this.state.transitions.cancel();
if (isStoredSession) {
await this.sessionsClient.delete(this.state.get().sessionId!);
}
}
/**
* Save current session as SO to get back to results later
* (Send to background)
*/
public async save(): Promise<void> {
const sessionId = this.getSessionId();
if (!sessionId) throw new Error('No current session');
if (!this.curApp) throw new Error('No current app id');
const currentSessionInfoProvider = this.searchSessionInfoProvider;
if (!currentSessionInfoProvider) throw new Error('No info provider for current session');
const [name, { initialState, restoreState, urlGeneratorId }] = await Promise.all([
currentSessionInfoProvider.getName(),
currentSessionInfoProvider.getUrlGeneratorData(),
]);
await this.sessionsClient.create({
name,
appId: this.curApp,
restoreState: (restoreState as unknown) as Record<string, unknown>,
initialState: (initialState as unknown) as Record<string, unknown>,
urlGeneratorId,
sessionId,
});
// if we are still interested in this result
if (this.getSessionId() === sessionId) {
this.state.transitions.store();
}
}
}

View file

@ -0,0 +1,124 @@
/*
* 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 { createSessionStateContainer, SessionState } from './session_state';
describe('Session state container', () => {
const { stateContainer: state } = createSessionStateContainer();
afterEach(() => {
state.transitions.clear();
});
describe('transitions', () => {
test('start', () => {
state.transitions.start();
expect(state.selectors.getState()).toBe(SessionState.None);
expect(state.get().sessionId).not.toBeUndefined();
});
test('track', () => {
expect(() => state.transitions.trackSearch({})).toThrowError();
state.transitions.start();
state.transitions.trackSearch({});
expect(state.selectors.getState()).toBe(SessionState.Loading);
});
test('untrack', () => {
state.transitions.start();
const search = {};
state.transitions.trackSearch(search);
expect(state.selectors.getState()).toBe(SessionState.Loading);
state.transitions.unTrackSearch(search);
expect(state.selectors.getState()).toBe(SessionState.Completed);
});
test('clear', () => {
state.transitions.start();
state.transitions.clear();
expect(state.selectors.getState()).toBe(SessionState.None);
expect(state.get().sessionId).toBeUndefined();
});
test('cancel', () => {
expect(() => state.transitions.cancel()).toThrowError();
state.transitions.start();
const search = {};
state.transitions.trackSearch(search);
expect(state.selectors.getState()).toBe(SessionState.Loading);
state.transitions.cancel();
expect(state.selectors.getState()).toBe(SessionState.Canceled);
state.transitions.clear();
expect(state.selectors.getState()).toBe(SessionState.None);
});
test('store -> completed', () => {
expect(() => state.transitions.store()).toThrowError();
state.transitions.start();
const search = {};
state.transitions.trackSearch(search);
expect(state.selectors.getState()).toBe(SessionState.Loading);
state.transitions.store();
expect(state.selectors.getState()).toBe(SessionState.BackgroundLoading);
state.transitions.unTrackSearch(search);
expect(state.selectors.getState()).toBe(SessionState.BackgroundCompleted);
state.transitions.clear();
expect(state.selectors.getState()).toBe(SessionState.None);
});
test('store -> cancel', () => {
state.transitions.start();
const search = {};
state.transitions.trackSearch(search);
expect(state.selectors.getState()).toBe(SessionState.Loading);
state.transitions.store();
expect(state.selectors.getState()).toBe(SessionState.BackgroundLoading);
state.transitions.cancel();
expect(state.selectors.getState()).toBe(SessionState.Canceled);
state.transitions.trackSearch(search);
expect(state.selectors.getState()).toBe(SessionState.Canceled);
state.transitions.start();
expect(state.selectors.getState()).toBe(SessionState.None);
});
test('restore', () => {
const id = 'id';
state.transitions.restore(id);
expect(state.selectors.getState()).toBe(SessionState.None);
const search = {};
state.transitions.trackSearch(search);
expect(state.selectors.getState()).toBe(SessionState.BackgroundLoading);
state.transitions.unTrackSearch(search);
expect(state.selectors.getState()).toBe(SessionState.Restored);
expect(() => state.transitions.store()).toThrowError();
expect(state.selectors.getState()).toBe(SessionState.Restored);
expect(() => state.transitions.cancel()).toThrowError();
expect(state.selectors.getState()).toBe(SessionState.Restored);
state.transitions.start();
expect(state.selectors.getState()).toBe(SessionState.None);
});
});
});

View file

@ -0,0 +1,234 @@
/*
* 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 { Observable } from 'rxjs';
import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators';
import { createStateContainer, StateContainer } from '../../../../kibana_utils/public';
/**
* Possible state that current session can be in
*
* @public
*/
export enum SessionState {
/**
* Session is not active, e.g. didn't start
*/
None = 'none',
/**
* Pending search request has not been sent to the background yet
*/
Loading = 'loading',
/**
* No action was taken and the page completed loading without background session creation.
*/
Completed = 'completed',
/**
* Search request was sent to the background.
* The page is loading in background.
*/
BackgroundLoading = 'backgroundLoading',
/**
* Page load completed with background session created.
*/
BackgroundCompleted = 'backgroundCompleted',
/**
* Revisiting the page after background completion
*/
Restored = 'restored',
/**
* Current session requests where explicitly canceled by user
* Displaying none or partial results
*/
Canceled = 'canceled',
}
/**
* Internal state of SessionService
* {@link SessionState} is inferred from this state
*
* @private
*/
export interface SessionStateInternal<SearchDescriptor = unknown> {
/**
* Current session Id
* Empty means there is no current active session.
*/
sessionId?: string;
/**
* Has the session already been stored (i.e. "sent to background")?
*/
isStored: boolean;
/**
* Is this session a restored session (have these requests already been made, and we're just
* looking to re-use the previous search IDs)?
*/
isRestore: boolean;
/**
* Set of currently running searches
* within a session and any info associated with them
*/
pendingSearches: SearchDescriptor[];
/**
* There was at least a single search in this session
*/
isStarted: boolean;
/**
* If user has explicitly canceled search requests
*/
isCanceled: boolean;
}
const createSessionDefaultState: <
SearchDescriptor = unknown
>() => SessionStateInternal<SearchDescriptor> = () => ({
sessionId: undefined,
isStored: false,
isRestore: false,
isCanceled: false,
isStarted: false,
pendingSearches: [],
});
export interface SessionPureTransitions<
SearchDescriptor = unknown,
S = SessionStateInternal<SearchDescriptor>
> {
start: (state: S) => () => S;
restore: (state: S) => (sessionId: string) => S;
clear: (state: S) => () => S;
store: (state: S) => () => S;
trackSearch: (state: S) => (search: SearchDescriptor) => S;
unTrackSearch: (state: S) => (search: SearchDescriptor) => S;
cancel: (state: S) => () => S;
}
export const sessionPureTransitions: SessionPureTransitions = {
start: (state) => () => ({ ...createSessionDefaultState(), sessionId: uuid.v4() }),
restore: (state) => (sessionId: string) => ({
...createSessionDefaultState(),
sessionId,
isRestore: true,
isStored: true,
}),
clear: (state) => () => createSessionDefaultState(),
store: (state) => () => {
if (!state.sessionId) throw new Error("Can't store session. Missing sessionId");
if (state.isStored || state.isRestore)
throw new Error('Can\'t store because current session is already stored"');
return {
...state,
isStored: true,
};
},
trackSearch: (state) => (search) => {
if (!state.sessionId) throw new Error("Can't track search. Missing sessionId");
return {
...state,
isStarted: true,
pendingSearches: state.pendingSearches.concat(search),
};
},
unTrackSearch: (state) => (search) => {
return {
...state,
pendingSearches: state.pendingSearches.filter((s) => s !== search),
};
},
cancel: (state) => () => {
if (!state.sessionId) throw new Error("Can't cancel searches. Missing sessionId");
if (state.isRestore) throw new Error("Can't cancel searches when restoring older searches");
return {
...state,
pendingSearches: [],
isCanceled: true,
isStored: false,
};
},
};
export interface SessionPureSelectors<
SearchDescriptor = unknown,
S = SessionStateInternal<SearchDescriptor>
> {
getState: (state: S) => () => SessionState;
}
export const sessionPureSelectors: SessionPureSelectors = {
getState: (state) => () => {
if (!state.sessionId) return SessionState.None;
if (!state.isStarted) return SessionState.None;
if (state.isCanceled) return SessionState.Canceled;
switch (true) {
case state.isRestore:
return state.pendingSearches.length > 0
? SessionState.BackgroundLoading
: SessionState.Restored;
case state.isStored:
return state.pendingSearches.length > 0
? SessionState.BackgroundLoading
: SessionState.BackgroundCompleted;
default:
return state.pendingSearches.length > 0 ? SessionState.Loading : SessionState.Completed;
}
return SessionState.None;
},
};
export type SessionStateContainer<SearchDescriptor = unknown> = StateContainer<
SessionStateInternal<SearchDescriptor>,
SessionPureTransitions<SearchDescriptor>,
SessionPureSelectors<SearchDescriptor>
>;
export const createSessionStateContainer = <SearchDescriptor = unknown>(
{ freeze = true }: { freeze: boolean } = { freeze: true }
): {
stateContainer: SessionStateContainer<SearchDescriptor>;
sessionState$: Observable<SessionState>;
} => {
const stateContainer = createStateContainer(
createSessionDefaultState(),
sessionPureTransitions,
sessionPureSelectors,
freeze ? undefined : { freeze: (s) => s }
) as SessionStateContainer<SearchDescriptor>;
const sessionState$: Observable<SessionState> = stateContainer.state$.pipe(
map(() => stateContainer.selectors.getState()),
distinctUntilChanged(),
shareReplay(1)
);
return {
stateContainer,
sessionState$,
};
};

View file

@ -0,0 +1,91 @@
/*
* 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 { PublicContract } from '@kbn/utility-types';
import { HttpSetup } from 'kibana/public';
import type { SavedObject, SavedObjectsFindResponse } from 'kibana/server';
import { BackgroundSessionSavedObjectAttributes, SearchSessionFindOptions } from '../../../common';
export type ISessionsClient = PublicContract<SessionsClient>;
export interface SessionsClientDeps {
http: HttpSetup;
}
/**
* CRUD backgroundSession SO
*/
export class SessionsClient {
private readonly http: HttpSetup;
constructor(deps: SessionsClientDeps) {
this.http = deps.http;
}
public get(sessionId: string): Promise<SavedObject<BackgroundSessionSavedObjectAttributes>> {
return this.http.get(`/internal/session/${encodeURIComponent(sessionId)}`);
}
public create({
name,
appId,
urlGeneratorId,
initialState,
restoreState,
sessionId,
}: {
name: string;
appId: string;
initialState: Record<string, unknown>;
restoreState: Record<string, unknown>;
urlGeneratorId: string;
sessionId: string;
}): Promise<SavedObject<BackgroundSessionSavedObjectAttributes>> {
return this.http.post(`/internal/session`, {
body: JSON.stringify({
name,
initialState,
restoreState,
sessionId,
appId,
urlGeneratorId,
}),
});
}
public find(
options: SearchSessionFindOptions
): Promise<SavedObjectsFindResponse<BackgroundSessionSavedObjectAttributes>> {
return this.http!.post(`/internal/session`, {
body: JSON.stringify(options),
});
}
public update(
sessionId: string,
attributes: Partial<BackgroundSessionSavedObjectAttributes>
): Promise<SavedObject<BackgroundSessionSavedObjectAttributes>> {
return this.http!.put(`/internal/session/${encodeURIComponent(sessionId)}`, {
body: JSON.stringify(attributes),
});
}
public delete(sessionId: string): Promise<void> {
return this.http!.delete(`/internal/session/${encodeURIComponent(sessionId)}`);
}
}

View file

@ -1,149 +0,0 @@
/*
* 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 { BehaviorSubject, Subscription } from 'rxjs';
import { HttpStart, PluginInitializerContext, StartServicesAccessor } from 'kibana/public';
import { ConfigSchema } from '../../config';
import {
ISessionService,
BackgroundSessionSavedObjectAttributes,
SearchSessionFindOptions,
} from '../../common';
export class SessionService implements ISessionService {
private session$ = new BehaviorSubject<string | undefined>(undefined);
private get sessionId() {
return this.session$.getValue();
}
private appChangeSubscription$?: Subscription;
private curApp?: string;
private http!: HttpStart;
/**
* Has the session already been stored (i.e. "sent to background")?
*/
private _isStored: boolean = false;
/**
* Is this session a restored session (have these requests already been made, and we're just
* looking to re-use the previous search IDs)?
*/
private _isRestore: boolean = false;
constructor(
initializerContext: PluginInitializerContext<ConfigSchema>,
getStartServices: StartServicesAccessor
) {
/*
Make sure that apps don't leave sessions open.
*/
getStartServices().then(([coreStart]) => {
this.http = coreStart.http;
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 isStored() {
return this._isStored;
}
public isRestore() {
return this._isRestore;
}
public start() {
this._isStored = false;
this._isRestore = false;
this.session$.next(uuid.v4());
return this.sessionId!;
}
public restore(sessionId: string) {
this._isStored = true;
this._isRestore = true;
this.session$.next(sessionId);
return this.http.get(`/internal/session/${encodeURIComponent(sessionId)}`);
}
public clear() {
this._isStored = false;
this._isRestore = false;
this.session$.next(undefined);
}
public async save(name: string, url: string) {
const response = await this.http.post(`/internal/session`, {
body: JSON.stringify({
name,
url,
sessionId: this.sessionId,
}),
});
this._isStored = true;
return response;
}
public get(sessionId: string) {
return this.http.get(`/internal/session/${encodeURIComponent(sessionId)}`);
}
public find(options: SearchSessionFindOptions) {
return this.http.post(`/internal/session`, {
body: JSON.stringify(options),
});
}
public update(sessionId: string, attributes: Partial<BackgroundSessionSavedObjectAttributes>) {
return this.http.put(`/internal/session/${encodeURIComponent(sessionId)}`, {
body: JSON.stringify(attributes),
});
}
public delete(sessionId: string) {
return this.http.delete(`/internal/session/${encodeURIComponent(sessionId)}`);
}
}

View file

@ -21,9 +21,10 @@ import { PackageInfo } from 'kibana/server';
import { ISearchInterceptor } from './search_interceptor';
import { SearchUsageCollector } from './collectors';
import { AggsSetup, AggsSetupDependencies, AggsStartDependencies, AggsStart } from './aggs';
import { ISearchGeneric, ISessionService, ISearchStartSearchSource } from '../../common/search';
import { ISearchGeneric, ISearchStartSearchSource } from '../../common/search';
import { IndexPatternsContract } from '../../common/index_patterns/index_patterns';
import { UsageCollectionSetup } from '../../../usage_collection/public';
import { ISessionsClient, ISessionService } from './session';
export { ISearchStartSearchSource };
@ -39,10 +40,15 @@ export interface ISearchSetup {
aggs: AggsSetup;
usageCollector?: SearchUsageCollector;
/**
* session management
* Current session management
* {@link ISessionService}
*/
session: ISessionService;
/**
* Background search sessions SO CRUD
* {@link ISessionsClient}
*/
sessionsClient: ISessionsClient;
/**
* @internal
*/
@ -73,10 +79,15 @@ export interface ISearchStart {
*/
searchSource: ISearchStartSearchSource;
/**
* session management
* Current session management
* {@link ISessionService}
*/
session: ISessionService;
/**
* Background search sessions SO CRUD
* {@link ISessionsClient}
*/
sessionsClient: ISessionsClient;
}
export { SEARCH_EVENT_TYPE } from './collectors';

View file

@ -39,6 +39,12 @@ export const backgroundSessionMapping: SavedObjectsType = {
status: {
type: 'keyword',
},
appId: {
type: 'keyword',
},
urlGeneratorId: {
type: 'keyword',
},
initialState: {
type: 'object',
enabled: false,

View file

@ -28,19 +28,31 @@ export function registerSessionRoutes(router: IRouter): void {
body: schema.object({
sessionId: schema.string(),
name: schema.string(),
appId: schema.string(),
expires: schema.maybe(schema.string()),
urlGeneratorId: schema.string(),
initialState: schema.maybe(schema.object({}, { unknowns: 'allow' })),
restoreState: schema.maybe(schema.object({}, { unknowns: 'allow' })),
}),
},
},
async (context, request, res) => {
const { sessionId, name, expires, initialState, restoreState } = request.body;
const {
sessionId,
name,
expires,
initialState,
restoreState,
appId,
urlGeneratorId,
} = request.body;
try {
const response = await context.search!.session.save(sessionId, {
name,
appId,
expires,
urlGeneratorId,
initialState,
restoreState,
});

View file

@ -33,6 +33,8 @@ describe('BackgroundSessionService', () => {
type: BACKGROUND_SESSION_TYPE,
attributes: {
name: 'my_name',
appId: 'my_app_id',
urlGeneratorId: 'my_url_generator_id',
idMapping: {},
},
references: [],
@ -121,6 +123,8 @@ describe('BackgroundSessionService', () => {
const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
const isStored = false;
const name = 'my saved background search session';
const appId = 'my_app_id';
const urlGeneratorId = 'my_url_generator_id';
const created = new Date().toISOString();
const expires = new Date().toISOString();
@ -133,7 +137,11 @@ describe('BackgroundSessionService', () => {
expect(savedObjectsClient.update).not.toHaveBeenCalled();
await service.save(sessionId, { name, created, expires }, { savedObjectsClient });
await service.save(
sessionId,
{ name, created, expires, appId, urlGeneratorId },
{ savedObjectsClient }
);
expect(savedObjectsClient.create).toHaveBeenCalledWith(
BACKGROUND_SESSION_TYPE,
@ -145,6 +153,8 @@ describe('BackgroundSessionService', () => {
restoreState: {},
status: BackgroundSessionStatus.IN_PROGRESS,
idMapping: { [requestHash]: searchId },
appId,
urlGeneratorId,
},
{ id: sessionId }
);
@ -215,6 +225,8 @@ describe('BackgroundSessionService', () => {
type: BACKGROUND_SESSION_TYPE,
attributes: {
name: 'my_name',
appId: 'my_app_id',
urlGeneratorId: 'my_url_generator_id',
idMapping: { [requestHash]: searchId },
},
references: [],

View file

@ -64,20 +64,34 @@ export class BackgroundSessionService {
sessionId: string,
{
name,
appId,
created = new Date().toISOString(),
expires = new Date(Date.now() + DEFAULT_EXPIRATION).toISOString(),
status = BackgroundSessionStatus.IN_PROGRESS,
urlGeneratorId,
initialState = {},
restoreState = {},
}: Partial<BackgroundSessionSavedObjectAttributes>,
{ savedObjectsClient }: BackgroundSessionDependencies
) => {
if (!name) throw new Error('Name is required');
if (!appId) throw new Error('AppId is required');
if (!urlGeneratorId) throw new Error('UrlGeneratorId is required');
// Get the mapping of request hash/search ID for this session
const searchMap = this.sessionSearchMap.get(sessionId) ?? new Map<string, string>();
const idMapping = Object.fromEntries(searchMap.entries());
const attributes = { name, created, expires, status, initialState, restoreState, idMapping };
const attributes = {
name,
created,
expires,
status,
initialState,
restoreState,
idMapping,
urlGeneratorId,
appId,
};
const session = await savedObjectsClient.create<BackgroundSessionSavedObjectAttributes>(
BACKGROUND_SESSION_TYPE,
attributes,

View file

@ -23,7 +23,7 @@ import { debounceTime } from 'rxjs/operators';
import moment from 'moment';
import dateMath from '@elastic/datemath';
import { i18n } from '@kbn/i18n';
import { getState, splitState } from './discover_state';
import { createSearchSessionRestorationDataProvider, getState, splitState } from './discover_state';
import { RequestAdapter } from '../../../../inspector/public';
import {
@ -60,14 +60,14 @@ import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_patte
import { addFatalError } from '../../../../kibana_legacy/public';
import { METRIC_TYPE } from '@kbn/analytics';
import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator';
import { removeQueryParam, getQueryParams } from '../../../../kibana_utils/public';
import { getQueryParams, removeQueryParam } from '../../../../kibana_utils/public';
import {
DEFAULT_COLUMNS_SETTING,
MODIFY_COLUMNS_ON_SWITCH,
SAMPLE_SIZE_SETTING,
SEARCH_ON_PAGE_LOAD_SETTING,
} from '../../../common';
import { resolveIndexPattern, loadIndexPattern } from '../helpers/resolve_index_pattern';
import { loadIndexPattern, resolveIndexPattern } from '../helpers/resolve_index_pattern';
import { getTopNavLinks } from '../components/top_nav/get_top_nav_links';
import { updateSearchSource } from '../helpers/update_search_source';
import { calcFieldCounts } from '../helpers/calc_field_counts';
@ -85,7 +85,7 @@ const {
toastNotifications,
uiSettings: config,
trackUiMetric,
} = services;
} = getServices();
const fetchStatuses = {
UNINITIALIZED: 'uninitialized',
@ -204,12 +204,20 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
// used for restoring background session
let isInitialSearch = true;
// search session requested a data refresh
subscriptions.add(
data.search.session.onRefresh$.subscribe(() => {
refetch$.next();
})
);
const state = getState({
getStateDefaults,
storeInSessionStorage: config.get('state:storeInSessionStorage'),
history,
toasts: core.notifications.toasts,
});
const {
appStateContainer,
startSync: startStateSync,
@ -280,6 +288,14 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise
}
});
data.search.session.setSearchSessionInfoProvider(
createSearchSessionRestorationDataProvider({
appStateContainer,
data,
getSavedSearchId: () => savedSearch.id,
})
);
$scope.setIndexPattern = async (id) => {
const nextIndexPattern = await indexPatterns.get(id);
if (nextIndexPattern) {

View file

@ -20,15 +20,23 @@ import { isEqual } from 'lodash';
import { History } from 'history';
import { NotificationsStart } from 'kibana/public';
import {
createStateContainer,
createKbnUrlStateStorage,
syncState,
ReduxLikeStateContainer,
createStateContainer,
IKbnUrlStateStorage,
ReduxLikeStateContainer,
StateContainer,
syncState,
withNotifyOnErrors,
} from '../../../../kibana_utils/public';
import { esFilters, Filter, Query } from '../../../../data/public';
import {
DataPublicPluginStart,
esFilters,
Filter,
Query,
SearchSessionInfoProvider,
} from '../../../../data/public';
import { migrateLegacyQuery } from '../helpers/migrate_legacy_query';
import { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from '../../url_generator';
export interface AppState {
/**
@ -247,3 +255,47 @@ export function isEqualState(stateA: AppState, stateB: AppState) {
const { filters: stateBFilters = [], ...stateBPartial } = stateB;
return isEqual(stateAPartial, stateBPartial) && isEqualFilters(stateAFilters, stateBFilters);
}
export function createSearchSessionRestorationDataProvider(deps: {
appStateContainer: StateContainer<AppState>;
data: DataPublicPluginStart;
getSavedSearchId: () => string | undefined;
}): SearchSessionInfoProvider {
return {
getName: async () => 'Discover',
getUrlGeneratorData: async () => {
return {
urlGeneratorId: DISCOVER_APP_URL_GENERATOR,
initialState: createUrlGeneratorState({ ...deps, forceAbsoluteTime: false }),
restoreState: createUrlGeneratorState({ ...deps, forceAbsoluteTime: true }),
};
},
};
}
function createUrlGeneratorState({
appStateContainer,
data,
getSavedSearchId,
forceAbsoluteTime, // TODO: not implemented
}: {
appStateContainer: StateContainer<AppState>;
data: DataPublicPluginStart;
getSavedSearchId: () => string | undefined;
forceAbsoluteTime: boolean;
}): DiscoverUrlGeneratorState {
const appState = appStateContainer.get();
return {
filters: data.query.filterManager.getFilters(),
indexPatternId: appState.index,
query: appState.query,
savedSearchId: getSavedSearchId(),
timeRange: data.query.timefilter.timefilter.getTime(), // TODO: handle relative time range
searchSessionId: data.search.session.getSessionId(),
columns: appState.columns,
sort: appState.sort,
savedQuery: appState.savedQuery,
interval: appState.interval,
useHash: false,
};
}

View file

@ -221,6 +221,19 @@ describe('Discover url generator', () => {
expect(url).toContain('__test__');
});
test('can specify columns, interval, sort and savedQuery', async () => {
const { generator } = await setup();
const url = await generator.createUrl({
columns: ['_source'],
interval: 'auto',
sort: [['timestamp, asc']],
savedQuery: '__savedQueryId__',
});
expect(url).toMatchInlineSnapshot(
`"xyz/app/discover#/?_g=()&_a=(columns:!(_source),interval:auto,savedQuery:__savedQueryId__,sort:!(!('timestamp,%20asc')))"`
);
});
describe('useHash property', () => {
describe('when default useHash is set to false', () => {
test('when using default, sets index pattern ID in the generated URL', async () => {

View file

@ -52,7 +52,7 @@ export interface DiscoverUrlGeneratorState {
refreshInterval?: RefreshInterval;
/**
* Optionally apply filers.
* Optionally apply filters.
*/
filters?: Filter[];
@ -72,6 +72,24 @@ export interface DiscoverUrlGeneratorState {
* Background search session id
*/
searchSessionId?: string;
/**
* Columns displayed in the table
*/
columns?: string[];
/**
* Used interval of the histogram
*/
interval?: string;
/**
* Array of the used sorting [[field,direction],...]
*/
sort?: string[][];
/**
* id of the used saved query
*/
savedQuery?: string;
}
interface Params {
@ -88,20 +106,28 @@ export class DiscoverUrlGenerator
public readonly id = DISCOVER_APP_URL_GENERATOR;
public readonly createUrl = async ({
useHash = this.params.useHash,
filters,
indexPatternId,
query,
refreshInterval,
savedSearchId,
timeRange,
useHash = this.params.useHash,
searchSessionId,
columns,
savedQuery,
sort,
interval,
}: DiscoverUrlGeneratorState): Promise<string> => {
const savedSearchPath = savedSearchId ? encodeURIComponent(savedSearchId) : '';
const appState: {
query?: Query;
filters?: Filter[];
index?: string;
columns?: string[];
interval?: string;
sort?: string[][];
savedQuery?: string;
} = {};
const queryState: QueryState = {};
@ -109,6 +135,10 @@ export class DiscoverUrlGenerator
if (filters && filters.length)
appState.filters = filters?.filter((f) => !esFilters.isFilterPinned(f));
if (indexPatternId) appState.index = indexPatternId;
if (columns) appState.columns = columns;
if (savedQuery) appState.savedQuery = savedQuery;
if (sort) appState.sort = sort;
if (interval) appState.interval = interval;
if (timeRange) queryState.time = timeRange;
if (filters && filters.length)

View file

@ -34,6 +34,7 @@ import { ExclusiveUnion } from '@elastic/eui';
import { ExpressionAstFunction } from 'src/plugins/expressions/common';
import { History } from 'history';
import { Href } from 'history';
import { HttpSetup as HttpSetup_2 } from 'kibana/public';
import { I18nStart as I18nStart_2 } from 'src/core/public';
import { IconType } from '@elastic/eui';
import { ISearchOptions } from 'src/plugins/data/public';
@ -56,7 +57,9 @@ import { OverlayStart as OverlayStart_2 } from 'src/core/public';
import { PackageInfo } from '@kbn/config';
import { Path } from 'history';
import { PluginInitializerContext } from 'src/core/public';
import { PluginInitializerContext as PluginInitializerContext_3 } from 'kibana/public';
import * as PropTypes from 'prop-types';
import { PublicContract } from '@kbn/utility-types';
import { PublicMethodsOf } from '@kbn/utility-types';
import { PublicUiSettingsParams } from 'src/core/server/types';
import React from 'react';
@ -77,6 +80,7 @@ import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/ex
import { ShallowPromise } from '@kbn/utility-types';
import { SimpleSavedObject as SimpleSavedObject_2 } from 'src/core/public';
import { Start as Start_2 } from 'src/plugins/inspector/public';
import { StartServicesAccessor as StartServicesAccessor_2 } from 'kibana/public';
import { ToastInputFields as ToastInputFields_2 } from 'src/core/public/notifications';
import { ToastsSetup as ToastsSetup_2 } from 'kibana/public';
import { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport';

View file

@ -68,6 +68,7 @@ export class DataEnhancedPlugin
React.createElement(
createConnectedBackgroundSessionIndicator({
sessionService: plugins.data.search.session,
application: core.application,
})
)
),

View file

@ -9,9 +9,10 @@ import { EnhancedSearchInterceptor } from './search_interceptor';
import { CoreSetup, CoreStart } from 'kibana/public';
import { UI_SETTINGS } from '../../../../../src/plugins/data/common';
import { AbortError } from '../../../../../src/plugins/kibana_utils/public';
import { SearchTimeoutError } from 'src/plugins/data/public';
import { ISessionService, SearchTimeoutError, SessionState } from 'src/plugins/data/public';
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks';
import { BehaviorSubject } from 'rxjs';
const timeTravel = (msToRun = 0) => {
jest.advanceTimersByTime(msToRun);
@ -43,11 +44,18 @@ function mockFetchImplementation(responses: any[]) {
describe('EnhancedSearchInterceptor', () => {
let mockUsageCollector: any;
let sessionService: jest.Mocked<ISessionService>;
let sessionState$: BehaviorSubject<SessionState>;
beforeEach(() => {
mockCoreSetup = coreMock.createSetup();
mockCoreStart = coreMock.createStart();
sessionState$ = new BehaviorSubject<SessionState>(SessionState.None);
const dataPluginMockStart = dataPluginMock.createStartContract();
sessionService = {
...(dataPluginMockStart.search.session as jest.Mocked<ISessionService>),
state$: sessionState$,
};
fetchMock = jest.fn();
mockCoreSetup.uiSettings.get.mockImplementation((name: string) => {
@ -87,7 +95,7 @@ describe('EnhancedSearchInterceptor', () => {
http: mockCoreSetup.http,
uiSettings: mockCoreSetup.uiSettings,
usageCollector: mockUsageCollector,
session: dataPluginMockStart.search.session,
session: sessionService,
});
});
@ -144,6 +152,7 @@ describe('EnhancedSearchInterceptor', () => {
},
},
];
mockFetchImplementation(responses);
const response = searchInterceptor.search({}, { pollInterval: 0 });
@ -361,6 +370,54 @@ describe('EnhancedSearchInterceptor', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(mockCoreSetup.http.delete).toHaveBeenCalled();
});
test('should NOT DELETE a running SAVED async search on abort', async () => {
const sessionId = 'sessionId';
sessionService.getSessionId.mockImplementation(() => sessionId);
const responses = [
{
time: 10,
value: {
isPartial: true,
isRunning: true,
id: 1,
},
},
{
time: 300,
value: {
isPartial: false,
isRunning: false,
id: 1,
},
},
];
mockFetchImplementation(responses);
const abortController = new AbortController();
setTimeout(() => abortController.abort(), 250);
const response = searchInterceptor.search(
{},
{ abortSignal: abortController.signal, pollInterval: 0, sessionId }
);
response.subscribe({ next, error });
await timeTravel(10);
expect(next).toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
sessionState$.next(SessionState.BackgroundLoading);
await timeTravel(240);
expect(error).toHaveBeenCalled();
expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(mockCoreSetup.http.delete).not.toHaveBeenCalled();
});
});
describe('cancelPending', () => {
@ -395,4 +452,108 @@ describe('EnhancedSearchInterceptor', () => {
expect(mockUsageCollector.trackQueriesCancelled).toBeCalledTimes(1);
});
});
describe('session', () => {
beforeEach(() => {
const responses = [
{
time: 10,
value: {
isPartial: true,
isRunning: true,
id: 1,
},
},
{
time: 300,
value: {
isPartial: false,
isRunning: false,
id: 1,
},
},
];
mockFetchImplementation(responses);
});
test('should track searches', async () => {
const sessionId = 'sessionId';
sessionService.getSessionId.mockImplementation(() => sessionId);
const untrack = jest.fn();
sessionService.trackSearch.mockImplementation(() => untrack);
const response = searchInterceptor.search({}, { pollInterval: 0, sessionId });
response.subscribe({ next, error });
await timeTravel(10);
expect(sessionService.trackSearch).toBeCalledTimes(1);
expect(untrack).not.toBeCalled();
await timeTravel(300);
expect(sessionService.trackSearch).toBeCalledTimes(1);
expect(untrack).toBeCalledTimes(1);
});
test('session service should be able to cancel search', async () => {
const sessionId = 'sessionId';
sessionService.getSessionId.mockImplementation(() => sessionId);
const untrack = jest.fn();
sessionService.trackSearch.mockImplementation(() => untrack);
const response = searchInterceptor.search({}, { pollInterval: 0, sessionId });
response.subscribe({ next, error });
await timeTravel(10);
expect(sessionService.trackSearch).toBeCalledTimes(1);
const abort = sessionService.trackSearch.mock.calls[0][0].abort;
expect(abort).toBeInstanceOf(Function);
abort();
await timeTravel(10);
expect(error).toHaveBeenCalled();
expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError);
});
test("don't track non current session searches", async () => {
const sessionId = 'sessionId';
sessionService.getSessionId.mockImplementation(() => sessionId);
const untrack = jest.fn();
sessionService.trackSearch.mockImplementation(() => untrack);
const response1 = searchInterceptor.search(
{},
{ pollInterval: 0, sessionId: 'something different' }
);
response1.subscribe({ next, error });
const response2 = searchInterceptor.search({}, { pollInterval: 0, sessionId: undefined });
response2.subscribe({ next, error });
await timeTravel(10);
expect(sessionService.trackSearch).toBeCalledTimes(0);
});
test("don't track if no current session", async () => {
sessionService.getSessionId.mockImplementation(() => undefined);
const untrack = jest.fn();
sessionService.trackSearch.mockImplementation(() => untrack);
const response1 = searchInterceptor.search(
{},
{ pollInterval: 0, sessionId: 'something different' }
);
response1.subscribe({ next, error });
const response2 = searchInterceptor.search({}, { pollInterval: 0, sessionId: undefined });
response2.subscribe({ next, error });
await timeTravel(10);
expect(sessionService.trackSearch).toBeCalledTimes(0);
});
});
});

View file

@ -5,13 +5,14 @@
*/
import { throwError, Subscription } from 'rxjs';
import { tap, finalize, catchError } from 'rxjs/operators';
import { tap, finalize, catchError, filter, take, skip } from 'rxjs/operators';
import {
TimeoutErrorMode,
SearchInterceptor,
SearchInterceptorDeps,
UI_SETTINGS,
IKibanaSearchRequest,
SessionState,
} from '../../../../../src/plugins/data/public';
import { AbortError } from '../../../../../src/plugins/kibana_utils/common';
import { ENHANCED_ES_SEARCH_STRATEGY, IAsyncSearchOptions, pollSearch } from '../../common';
@ -54,7 +55,7 @@ export class EnhancedSearchInterceptor extends SearchInterceptor {
};
public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) {
const { combinedSignal, timeoutSignal, cleanup } = this.setupAbortSignal({
const { combinedSignal, timeoutSignal, cleanup, abort } = this.setupAbortSignal({
abortSignal: options.abortSignal,
timeout: this.searchTimeout,
});
@ -63,16 +64,41 @@ export class EnhancedSearchInterceptor extends SearchInterceptor {
const search = () => this.runSearch({ id, ...request }, searchOptions);
this.pendingCount$.next(this.pendingCount$.getValue() + 1);
const isCurrentSession = () =>
!!options.sessionId && options.sessionId === this.deps.session.getSessionId();
const untrackSearch = isCurrentSession() && this.deps.session.trackSearch({ abort });
// track if this search's session will be send to background
// if yes, then we don't need to cancel this search when it is aborted
let isSavedToBackground = false;
const savedToBackgroundSub =
isCurrentSession() &&
this.deps.session.state$
.pipe(
skip(1), // ignore any state, we are only interested in transition x -> BackgroundLoading
filter((state) => isCurrentSession() && state === SessionState.BackgroundLoading),
take(1)
)
.subscribe(() => {
isSavedToBackground = true;
});
return pollSearch(search, { ...options, abortSignal: combinedSignal }).pipe(
tap((response) => (id = response.id)),
catchError((e: AbortError) => {
if (id) this.deps.http.delete(`/internal/search/${strategy}/${id}`);
if (id && !isSavedToBackground) this.deps.http.delete(`/internal/search/${strategy}/${id}`);
return throwError(this.handleSearchError(e, timeoutSignal, options));
}),
finalize(() => {
this.pendingCount$.next(this.pendingCount$.getValue() - 1);
cleanup();
if (untrackSearch && isCurrentSession()) {
untrackSearch();
}
if (savedToBackgroundSub) {
savedToBackgroundSub.unsubscribe();
}
})
);
}

View file

@ -7,24 +7,24 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { BackgroundSessionIndicator } from './background_session_indicator';
import { BackgroundSessionViewState } from '../connected_background_session_indicator';
import { SessionState } from '../../../../../../../src/plugins/data/public';
storiesOf('components/BackgroundSessionIndicator', module).add('default', () => (
<>
<div>
<BackgroundSessionIndicator state={BackgroundSessionViewState.Loading} />
<BackgroundSessionIndicator state={SessionState.Loading} />
</div>
<div>
<BackgroundSessionIndicator state={BackgroundSessionViewState.Completed} />
<BackgroundSessionIndicator state={SessionState.Completed} />
</div>
<div>
<BackgroundSessionIndicator state={BackgroundSessionViewState.BackgroundLoading} />
<BackgroundSessionIndicator state={SessionState.BackgroundLoading} />
</div>
<div>
<BackgroundSessionIndicator state={BackgroundSessionViewState.BackgroundCompleted} />
<BackgroundSessionIndicator state={SessionState.BackgroundCompleted} />
</div>
<div>
<BackgroundSessionIndicator state={BackgroundSessionViewState.Restored} />
<BackgroundSessionIndicator state={SessionState.Restored} />
</div>
</>
));

View file

@ -8,8 +8,8 @@ import React, { ReactNode } from 'react';
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BackgroundSessionIndicator } from './background_session_indicator';
import { BackgroundSessionViewState } from '../connected_background_session_indicator';
import { IntlProvider } from 'react-intl';
import { SessionState } from '../../../../../../../src/plugins/data/public';
function Container({ children }: { children?: ReactNode }) {
return <IntlProvider locale="en">{children}</IntlProvider>;
@ -19,7 +19,7 @@ test('Loading state', async () => {
const onCancel = jest.fn();
render(
<Container>
<BackgroundSessionIndicator state={BackgroundSessionViewState.Loading} onCancel={onCancel} />
<BackgroundSessionIndicator state={SessionState.Loading} onCancel={onCancel} />
</Container>
);
@ -33,10 +33,7 @@ test('Completed state', async () => {
const onSave = jest.fn();
render(
<Container>
<BackgroundSessionIndicator
state={BackgroundSessionViewState.Completed}
onSaveResults={onSave}
/>
<BackgroundSessionIndicator state={SessionState.Completed} onSaveResults={onSave} />
</Container>
);
@ -50,10 +47,7 @@ test('Loading in the background state', async () => {
const onCancel = jest.fn();
render(
<Container>
<BackgroundSessionIndicator
state={BackgroundSessionViewState.BackgroundLoading}
onCancel={onCancel}
/>
<BackgroundSessionIndicator state={SessionState.BackgroundLoading} onCancel={onCancel} />
</Container>
);
@ -64,30 +58,26 @@ test('Loading in the background state', async () => {
});
test('BackgroundCompleted state', async () => {
const onViewSession = jest.fn();
render(
<Container>
<BackgroundSessionIndicator
state={BackgroundSessionViewState.BackgroundCompleted}
onViewBackgroundSessions={onViewSession}
state={SessionState.BackgroundCompleted}
viewBackgroundSessionsLink={'__link__'}
/>
</Container>
);
await userEvent.click(screen.getByLabelText('Results loaded in the background'));
await userEvent.click(screen.getByText('View background sessions'));
expect(onViewSession).toBeCalled();
expect(screen.getByRole('link', { name: 'View background sessions' }).getAttribute('href')).toBe(
'__link__'
);
});
test('Restored state', async () => {
const onRefresh = jest.fn();
render(
<Container>
<BackgroundSessionIndicator
state={BackgroundSessionViewState.Restored}
onRefresh={onRefresh}
/>
<BackgroundSessionIndicator state={SessionState.Restored} onRefresh={onRefresh} />
</Container>
);
@ -96,3 +86,17 @@ test('Restored state', async () => {
expect(onRefresh).toBeCalled();
});
test('Canceled state', async () => {
const onRefresh = jest.fn();
render(
<Container>
<BackgroundSessionIndicator state={SessionState.Canceled} onRefresh={onRefresh} />
</Container>
);
await userEvent.click(screen.getByLabelText('Canceled'));
await userEvent.click(screen.getByText('Refresh'));
expect(onRefresh).toBeCalled();
});

View file

@ -19,14 +19,15 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { BackgroundSessionViewState } from '../connected_background_session_indicator';
import './background_session_indicator.scss';
import { SessionState } from '../../../../../../../src/plugins/data/public/';
export interface BackgroundSessionIndicatorProps {
state: BackgroundSessionViewState;
state: SessionState;
onContinueInBackground?: () => void;
onCancel?: () => void;
onViewBackgroundSessions?: () => void;
viewBackgroundSessionsLink?: string;
onSaveResults?: () => void;
onRefresh?: () => void;
}
@ -34,7 +35,11 @@ export interface BackgroundSessionIndicatorProps {
type ActionButtonProps = BackgroundSessionIndicatorProps & { buttonProps: EuiButtonEmptyProps };
const CancelButton = ({ onCancel = () => {}, buttonProps = {} }: ActionButtonProps) => (
<EuiButtonEmpty onClick={onCancel} {...buttonProps}>
<EuiButtonEmpty
onClick={onCancel}
data-test-subj={'backgroundSessionIndicatorCancelBtn'}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.backgroundSessionIndicator.cancelButtonText"
defaultMessage="Cancel"
@ -46,7 +51,11 @@ const ContinueInBackgroundButton = ({
onContinueInBackground = () => {},
buttonProps = {},
}: ActionButtonProps) => (
<EuiButtonEmpty onClick={onContinueInBackground} {...buttonProps}>
<EuiButtonEmpty
onClick={onContinueInBackground}
data-test-subj={'backgroundSessionIndicatorContinueInBackgroundBtn'}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.backgroundSessionIndicator.continueInBackgroundButtonText"
defaultMessage="Continue in background"
@ -55,11 +64,14 @@ const ContinueInBackgroundButton = ({
);
const ViewBackgroundSessionsButton = ({
onViewBackgroundSessions = () => {},
viewBackgroundSessionsLink = 'management',
buttonProps = {},
}: ActionButtonProps) => (
// TODO: make this a link
<EuiButtonEmpty onClick={onViewBackgroundSessions} {...buttonProps}>
<EuiButtonEmpty
href={viewBackgroundSessionsLink}
data-test-subj={'backgroundSessionIndicatorViewBackgroundSessionsLink'}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.backgroundSessionIndicator.viewBackgroundSessionsLinkText"
defaultMessage="View background sessions"
@ -68,7 +80,11 @@ const ViewBackgroundSessionsButton = ({
);
const RefreshButton = ({ onRefresh = () => {}, buttonProps = {} }: ActionButtonProps) => (
<EuiButtonEmpty onClick={onRefresh} {...buttonProps}>
<EuiButtonEmpty
onClick={onRefresh}
data-test-subj={'backgroundSessionIndicatorRefreshBtn'}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.backgroundSessionIndicator.refreshButtonText"
defaultMessage="Refresh"
@ -77,7 +93,11 @@ const RefreshButton = ({ onRefresh = () => {}, buttonProps = {} }: ActionButtonP
);
const SaveButton = ({ onSaveResults = () => {}, buttonProps = {} }: ActionButtonProps) => (
<EuiButtonEmpty onClick={onSaveResults} {...buttonProps}>
<EuiButtonEmpty
onClick={onSaveResults}
data-test-subj={'backgroundSessionIndicatorSaveBtn'}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.backgroundSessionIndicator.saveButtonText"
defaultMessage="Save"
@ -86,16 +106,19 @@ const SaveButton = ({ onSaveResults = () => {}, buttonProps = {} }: ActionButton
);
const backgroundSessionIndicatorViewStateToProps: {
[state in BackgroundSessionViewState]: {
button: Pick<EuiButtonIconProps, 'color' | 'iconType' | 'aria-label'> & { tooltipText: string };
[state in SessionState]: {
button: Pick<EuiButtonIconProps, 'color' | 'iconType' | 'aria-label'> & {
tooltipText: string;
};
popover: {
text: string;
primaryAction?: React.ComponentType<ActionButtonProps>;
secondaryAction?: React.ComponentType<ActionButtonProps>;
};
};
} | null;
} = {
[BackgroundSessionViewState.Loading]: {
[SessionState.None]: null,
[SessionState.Loading]: {
button: {
color: 'subdued',
iconType: 'clock',
@ -116,7 +139,7 @@ const backgroundSessionIndicatorViewStateToProps: {
secondaryAction: ContinueInBackgroundButton,
},
},
[BackgroundSessionViewState.Completed]: {
[SessionState.Completed]: {
button: {
color: 'subdued',
iconType: 'checkInCircleFilled',
@ -141,7 +164,7 @@ const backgroundSessionIndicatorViewStateToProps: {
secondaryAction: ViewBackgroundSessionsButton,
},
},
[BackgroundSessionViewState.BackgroundLoading]: {
[SessionState.BackgroundLoading]: {
button: {
iconType: EuiLoadingSpinner,
'aria-label': i18n.translate(
@ -165,7 +188,7 @@ const backgroundSessionIndicatorViewStateToProps: {
secondaryAction: ViewBackgroundSessionsButton,
},
},
[BackgroundSessionViewState.BackgroundCompleted]: {
[SessionState.BackgroundCompleted]: {
button: {
color: 'success',
iconType: 'checkInCircleFilled',
@ -192,7 +215,7 @@ const backgroundSessionIndicatorViewStateToProps: {
primaryAction: ViewBackgroundSessionsButton,
},
},
[BackgroundSessionViewState.Restored]: {
[SessionState.Restored]: {
button: {
color: 'warning',
iconType: 'refresh',
@ -217,6 +240,25 @@ const backgroundSessionIndicatorViewStateToProps: {
secondaryAction: ViewBackgroundSessionsButton,
},
},
[SessionState.Canceled]: {
button: {
color: 'subdued',
iconType: 'refresh',
'aria-label': i18n.translate('xpack.data.backgroundSessionIndicator.canceledIconAriaLabel', {
defaultMessage: 'Canceled',
}),
tooltipText: i18n.translate('xpack.data.backgroundSessionIndicator.canceledTooltipText', {
defaultMessage: 'Search was canceled',
}),
},
popover: {
text: i18n.translate('xpack.data.backgroundSessionIndicator.canceledText', {
defaultMessage: 'Search was canceled',
}),
primaryAction: RefreshButton,
secondaryAction: ViewBackgroundSessionsButton,
},
},
};
const VerticalDivider: React.FC = () => (
@ -228,7 +270,9 @@ export const BackgroundSessionIndicator: React.FC<BackgroundSessionIndicatorProp
const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen);
const closePopover = () => setIsPopoverOpen(false);
const { button, popover } = backgroundSessionIndicatorViewStateToProps[props.state];
if (!backgroundSessionIndicatorViewStateToProps[props.state]) return null;
const { button, popover } = backgroundSessionIndicatorViewStateToProps[props.state]!;
return (
<EuiPopover
@ -239,6 +283,7 @@ export const BackgroundSessionIndicator: React.FC<BackgroundSessionIndicatorProp
panelPaddingSize={'s'}
className="backgroundSessionIndicator"
data-test-subj={'backgroundSessionIndicator'}
data-state={props.state}
button={
<EuiToolTip content={button.tooltipText}>
<EuiButtonIcon
@ -255,6 +300,7 @@ export const BackgroundSessionIndicator: React.FC<BackgroundSessionIndicatorProp
alignItems={'center'}
gutterSize={'s'}
className="backgroundSessionIndicator__popoverContainer"
data-test-subj={'backgroundSessionIndicatorPopoverContainer'}
>
<EuiFlexItem grow={true}>
<EuiText size="s" color={'subdued'}>

View file

@ -9,13 +9,18 @@ import { render, waitFor } from '@testing-library/react';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { createConnectedBackgroundSessionIndicator } from './connected_background_session_indicator';
import { BehaviorSubject } from 'rxjs';
import { ISessionService } from '../../../../../../../src/plugins/data/public';
import { ISessionService, SessionState } from '../../../../../../../src/plugins/data/public';
import { coreMock } from '../../../../../../../src/core/public/mocks';
const coreStart = coreMock.createStart();
const sessionService = dataPluginMock.createStartContract().search
.session as jest.Mocked<ISessionService>;
test("shouldn't show indicator in case no active search session", async () => {
const BackgroundSessionIndicator = createConnectedBackgroundSessionIndicator({ sessionService });
const BackgroundSessionIndicator = createConnectedBackgroundSessionIndicator({
sessionService,
application: coreStart.application,
});
const { getByTestId, container } = render(<BackgroundSessionIndicator />);
// make sure `backgroundSessionIndicator` isn't appearing after some time (lazy-loading)
@ -26,10 +31,11 @@ test("shouldn't show indicator in case no active search session", async () => {
});
test('should show indicator in case there is an active search session', async () => {
const session$ = new BehaviorSubject('session_id');
sessionService.getSession$.mockImplementation(() => session$);
sessionService.getSessionId.mockImplementation(() => session$.getValue());
const BackgroundSessionIndicator = createConnectedBackgroundSessionIndicator({ sessionService });
const state$ = new BehaviorSubject(SessionState.Loading);
const BackgroundSessionIndicator = createConnectedBackgroundSessionIndicator({
sessionService: { ...sessionService, state$ },
application: coreStart.application,
});
const { getByTestId } = render(<BackgroundSessionIndicator />);
await waitFor(() => getByTestId('backgroundSessionIndicator'));

View file

@ -5,28 +5,43 @@
*/
import React from 'react';
import { debounceTime } from 'rxjs/operators';
import useObservable from 'react-use/lib/useObservable';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { BackgroundSessionIndicator } from '../background_session_indicator';
import { ISessionService } from '../../../../../../../src/plugins/data/public/';
import { BackgroundSessionViewState } from './background_session_view_state';
import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public';
import { ApplicationStart } from '../../../../../../../src/core/public';
export interface BackgroundSessionIndicatorDeps {
sessionService: ISessionService;
application: ApplicationStart;
}
export const createConnectedBackgroundSessionIndicator = ({
sessionService,
application,
}: BackgroundSessionIndicatorDeps): React.FC => {
const sessionId$ = sessionService.getSession$();
const hasActiveSession$ = sessionId$.pipe(
map((sessionId) => !!sessionId),
distinctUntilChanged()
);
return () => {
const isSession = useObservable(hasActiveSession$, !!sessionService.getSessionId());
if (!isSession) return null;
return <BackgroundSessionIndicator state={BackgroundSessionViewState.Loading} />;
const state = useObservable(sessionService.state$.pipe(debounceTime(500)));
if (!state) return null;
return (
<RedirectAppLinks application={application}>
<BackgroundSessionIndicator
state={state}
onContinueInBackground={() => {
sessionService.save();
}}
onSaveResults={() => {
sessionService.save();
}}
onRefresh={() => {
sessionService.refresh();
}}
onCancel={() => {
sessionService.cancel();
}}
/>
</RedirectAppLinks>
);
};
};

View file

@ -8,4 +8,3 @@ export {
BackgroundSessionIndicatorDeps,
createConnectedBackgroundSessionIndicator,
} from './connected_background_session_indicator';
export { BackgroundSessionViewState } from './background_session_view_state';

View file

@ -45,13 +45,13 @@ describe('alert actions', () => {
updateTimelineIsLoading = jest.fn() as jest.Mocked<UpdateTimelineLoading>;
searchStrategyClient = {
...dataPluginMock.createStartContract().search,
aggs: {} as ISearchStart['aggs'],
showError: jest.fn(),
search: jest
.fn()
.mockImplementation(() => ({ toPromise: () => ({ data: mockTimelineDetails }) })),
searchSource: {} as ISearchStart['searchSource'],
session: dataPluginMock.createStartContract().search.session,
};
jest.spyOn(apolloClient, 'query').mockImplementation((obj) => {

View file

@ -16,6 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const inspector = getService('inspector');
const queryBar = getService('queryBar');
const browser = getService('browser');
const sendToBackground = getService('sendToBackground');
describe('dashboard with async search', () => {
before(async function () {
@ -78,21 +79,53 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(panel1SessionId1).not.to.be(panel1SessionId2);
});
// NOTE: this test will be revised when session functionality is really working
it('Opens a dashboard with existing session', async () => {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.loadSavedDashboard('Not Delayed');
const url = await browser.getCurrentUrl();
const fakeSessionId = '__fake__';
const savedSessionURL = `${url}&searchSessionId=${fakeSessionId}`;
await browser.navigateTo(savedSessionURL);
await PageObjects.header.waitUntilLoadingHasFinished();
const session1 = await getSearchSessionIdByPanel('Sum of Bytes by Extension');
expect(session1).to.be(fakeSessionId);
await queryBar.clickQuerySubmitButton();
await PageObjects.header.waitUntilLoadingHasFinished();
const session2 = await getSearchSessionIdByPanel('Sum of Bytes by Extension');
expect(session2).not.to.be(fakeSessionId);
describe('Send to background', () => {
before(async () => {
await PageObjects.common.navigateToApp('dashboard');
});
it('Restore using non-existing sessionId errors out. Refresh starts a new session and completes.', async () => {
await PageObjects.dashboard.loadSavedDashboard('Not Delayed');
const url = await browser.getCurrentUrl();
const fakeSessionId = '__fake__';
const savedSessionURL = `${url}&searchSessionId=${fakeSessionId}`;
await browser.get(savedSessionURL);
await PageObjects.header.waitUntilLoadingHasFinished();
await sendToBackground.expectState('restored');
await testSubjects.existOrFail('embeddableErrorLabel'); // expected that panel errors out because of non existing session
const session1 = await getSearchSessionIdByPanel('Sum of Bytes by Extension');
expect(session1).to.be(fakeSessionId);
await sendToBackground.refresh();
await PageObjects.header.waitUntilLoadingHasFinished();
await sendToBackground.expectState('completed');
await testSubjects.missingOrFail('embeddableErrorLabel');
const session2 = await getSearchSessionIdByPanel('Sum of Bytes by Extension');
expect(session2).not.to.be(fakeSessionId);
});
it('Saves and restores a session', async () => {
await PageObjects.dashboard.loadSavedDashboard('Not Delayed');
await PageObjects.dashboard.waitForRenderComplete();
await sendToBackground.expectState('completed');
await sendToBackground.save();
await sendToBackground.expectState('backgroundCompleted');
const savedSessionId = await getSearchSessionIdByPanel('Sum of Bytes by Extension');
// load URL to restore a saved session
const url = await browser.getCurrentUrl();
const savedSessionURL = `${url}&searchSessionId=${savedSessionId}`;
await browser.get(savedSessionURL);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
// Check that session is restored
await sendToBackground.expectState('restored');
await testSubjects.missingOrFail('embeddableErrorLabel');
const data = await PageObjects.visChart.getBarChartData('Sum of bytes');
expect(data.length).to.be(5);
});
});
});

View file

@ -89,6 +89,7 @@ export default async function ({ readConfigFile }) {
'--xpack.encryptedSavedObjects.encryptionKey="DkdXazszSCYexXqz4YktBGHCRkV6hyNK"',
'--timelion.ui.enabled=true',
'--savedObjects.maxImportPayloadBytes=10485760', // for OSS test management/_import_objects
'--xpack.data_enhanced.search.sendToBackground.enabled=true', // enable WIP send to background UI
],
},
uiSettings: {

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SendToBackgroundProvider } from './send_to_background';

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper';
const SEND_TO_BACKGROUND_TEST_SUBJ = 'backgroundSessionIndicator';
const SEND_TO_BACKGROUND_POPOVER_CONTENT_TEST_SUBJ = 'backgroundSessionIndicatorPopoverContainer';
type SessionStateType =
| 'none'
| 'loading'
| 'completed'
| 'backgroundLoading'
| 'backgroundCompleted'
| 'restored'
| 'canceled';
export function SendToBackgroundProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const retry = getService('retry');
const browser = getService('browser');
return new (class SendToBackgroundService {
public async find(): Promise<WebElementWrapper> {
return testSubjects.find(SEND_TO_BACKGROUND_TEST_SUBJ);
}
public async exists(): Promise<boolean> {
return testSubjects.exists(SEND_TO_BACKGROUND_TEST_SUBJ);
}
public async expectState(state: SessionStateType) {
return retry.waitFor(`sendToBackground indicator to get into state = ${state}`, async () => {
const currentState = await (
await testSubjects.find(SEND_TO_BACKGROUND_TEST_SUBJ)
).getAttribute('data-state');
return currentState === state;
});
}
public async viewBackgroundSessions() {
await this.ensurePopoverOpened();
await testSubjects.click('backgroundSessionIndicatorViewBackgroundSessionsLink');
}
public async save() {
await this.ensurePopoverOpened();
await testSubjects.click('backgroundSessionIndicatorSaveBtn');
await this.ensurePopoverClosed();
}
public async cancel() {
await this.ensurePopoverOpened();
await testSubjects.click('backgroundSessionIndicatorCancelBtn');
await this.ensurePopoverClosed();
}
public async refresh() {
await this.ensurePopoverOpened();
await testSubjects.click('backgroundSessionIndicatorRefreshBtn');
await this.ensurePopoverClosed();
}
private async ensurePopoverOpened() {
const isAlreadyOpen = await testSubjects.exists(SEND_TO_BACKGROUND_POPOVER_CONTENT_TEST_SUBJ);
if (isAlreadyOpen) return;
return retry.waitFor(`sendToBackground popover opened`, async () => {
await testSubjects.click(SEND_TO_BACKGROUND_TEST_SUBJ);
return await testSubjects.exists(SEND_TO_BACKGROUND_POPOVER_CONTENT_TEST_SUBJ);
});
}
private async ensurePopoverClosed() {
const isAlreadyClosed = !(await testSubjects.exists(
SEND_TO_BACKGROUND_POPOVER_CONTENT_TEST_SUBJ
));
if (isAlreadyClosed) return;
return retry.waitFor(`sendToBackground popover closed`, async () => {
await browser.pressKeys(browser.keys.ESCAPE);
return !(await testSubjects.exists(SEND_TO_BACKGROUND_POPOVER_CONTENT_TEST_SUBJ));
});
}
})();
}

View file

@ -56,6 +56,7 @@ import {
DashboardDrilldownsManageProvider,
DashboardPanelTimeRangeProvider,
} from './dashboard';
import { SendToBackgroundProvider } from './data';
// define the name and providers for services that should be
// available to your tests. If you don't specify anything here
@ -103,4 +104,5 @@ export const services = {
dashboardDrilldownPanelActions: DashboardDrilldownPanelActionsProvider,
dashboardDrilldownsManage: DashboardDrilldownsManageProvider,
dashboardPanelTimeRange: DashboardPanelTimeRangeProvider,
sendToBackground: SendToBackgroundProvider,
};