"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.SessionService = void 0; var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _operators = require("rxjs/operators"); var _rxjs = require("rxjs"); var _i18n = require("@kbn/i18n"); var _moment = _interopRequireDefault(require("moment")); var _search_session_state = require("./search_session_state"); var _constants = require("./constants"); var _session_name_formatter = require("./lib/session_name_formatter"); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License * 2.0 and the Server Side Public License, v 1; you may not use this file except * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ /** * Polling interval for keeping completed searches alive * until the user saves the session */ const KEEP_ALIVE_COMPLETED_SEARCHES_INTERVAL = 30000; /** * Responsible for tracking a current search session. Supports a single session at a time. */ class SessionService { /** * Emits `true` when session completes and `config.search.sessions.notTouchedTimeout` duration has passed. * Used to stop keeping searches alive after some times and disabled "save session" button * * or when failed to extend searches after session completes */ /** * Emits `true` when it is no longer possible to save a session: * - Failed to keep searches alive after they completed * - `config.search.sessions.notTouchedTimeout` after searches completed hit * - Continued session from a different app and lost information about previous searches (https://github.com/elastic/kibana/issues/121543) */ /** * Holds snapshot of last cleared session so that it can be continued * Can be used to re-use a session between apps * @private */ constructor(initializerContext, getStartServices, sessionsClient, nowProvider, usageCollector, { freezeState = true } = { freezeState: true }) { (0, _defineProperty2.default)(this, "state$", void 0); (0, _defineProperty2.default)(this, "state", void 0); (0, _defineProperty2.default)(this, "sessionMeta$", void 0); (0, _defineProperty2.default)(this, "_disableSaveAfterSearchesExpire$", new _rxjs.BehaviorSubject(false)); (0, _defineProperty2.default)(this, "disableSaveAfterSearchesExpire$", void 0); (0, _defineProperty2.default)(this, "searchSessionInfoProvider", void 0); (0, _defineProperty2.default)(this, "searchSessionIndicatorUiConfig", void 0); (0, _defineProperty2.default)(this, "subscription", new _rxjs.Subscription()); (0, _defineProperty2.default)(this, "currentApp", void 0); (0, _defineProperty2.default)(this, "hasAccessToSearchSessions", false); (0, _defineProperty2.default)(this, "toastService", void 0); (0, _defineProperty2.default)(this, "lastSessionSnapshot", void 0); this.sessionsClient = sessionsClient; this.nowProvider = nowProvider; this.usageCollector = usageCollector; const { stateContainer, sessionState$, sessionMeta$ } = (0, _search_session_state.createSessionStateContainer)({ freeze: freezeState }); this.state$ = sessionState$; this.state = stateContainer; this.sessionMeta$ = sessionMeta$; this.disableSaveAfterSearchesExpire$ = (0, _rxjs.combineLatest)([this._disableSaveAfterSearchesExpire$, this.sessionMeta$.pipe((0, _operators.map)(meta => meta.isContinued))]).pipe((0, _operators.map)(([_disableSaveAfterSearchesExpire, isSessionContinued]) => _disableSaveAfterSearchesExpire || isSessionContinued), (0, _operators.distinctUntilChanged)()); const notTouchedTimeout = _moment.default.duration(initializerContext.config.get().search.sessions.notTouchedTimeout).asMilliseconds(); this.subscription.add(this.state$.pipe((0, _operators.switchMap)(_state => _state === _search_session_state.SearchSessionState.Completed ? (0, _rxjs.merge)((0, _rxjs.of)(false), (0, _rxjs.timer)(notTouchedTimeout).pipe((0, _operators.mapTo)(true))) : (0, _rxjs.of)(false)), (0, _operators.distinctUntilChanged)(), (0, _operators.tap)(value => { var _this$usageCollector; if (value) (_this$usageCollector = this.usageCollector) === null || _this$usageCollector === void 0 ? void 0 : _this$usageCollector.trackSessionIndicatorSaveDisabled(); })).subscribe(this._disableSaveAfterSearchesExpire$)); this.subscription.add(sessionMeta$.pipe((0, _operators.map)(meta => meta.startTime), (0, _operators.distinctUntilChanged)()).subscribe(startTime => { if (startTime) this.nowProvider.set(startTime);else this.nowProvider.reset(); })); getStartServices().then(([coreStart]) => { var _coreStart$applicatio, _coreStart$applicatio2; // using management?.kibana? we infer if any of the apps allows current user to store sessions this.hasAccessToSearchSessions = (_coreStart$applicatio = coreStart.application.capabilities.management) === null || _coreStart$applicatio === void 0 ? void 0 : (_coreStart$applicatio2 = _coreStart$applicatio.kibana) === null || _coreStart$applicatio2 === void 0 ? void 0 : _coreStart$applicatio2[_constants.SEARCH_SESSIONS_MANAGEMENT_ID]; this.toastService = coreStart.notifications.toasts; this.subscription.add(coreStart.application.currentAppId$.subscribe(newAppName => { this.currentApp = newAppName; if (!this.getSessionId()) return; // Apps required to clean up their sessions before unmounting // Make sure that apps don't leave sessions open by throwing an error in DEV mode const message = `Application '${this.state.get().appName}' had an open session while navigating`; if (initializerContext.env.mode.dev) { coreStart.fatalErrors.add(message); } else { // this should never happen in prod because should be caught in dev mode // in case this happen we don't want to throw fatal error, as most likely possible bugs are not that critical // eslint-disable-next-line no-console console.warn(message); } })); }); // keep completed searches alive until user explicitly saves the session this.subscription.add(this.getSession$().pipe((0, _operators.switchMap)(sessionId => { if (!sessionId) return _rxjs.EMPTY; if (this.isStored()) return _rxjs.EMPTY; // no need to keep searches alive because session and searches are already stored if (!this.hasAccess()) return _rxjs.EMPTY; // don't need to keep searches alive if the user can't save session if (!this.isSessionStorageReady()) return _rxjs.EMPTY; // don't need to keep searches alive if app doesn't allow saving session const schedulePollSearches = () => { return (0, _rxjs.timer)(KEEP_ALIVE_COMPLETED_SEARCHES_INTERVAL).pipe((0, _operators.mergeMap)(() => { const searchesToKeepAlive = this.state.get().trackedSearches.filter(s => !s.searchMeta.isStored && s.state === _search_session_state.TrackedSearchState.Completed && s.searchMeta.lastPollingTime.getTime() < Date.now() - 5000 // don't poll if was very recently polled ); return (0, _rxjs.from)(Promise.all(searchesToKeepAlive.map(s => s.searchDescriptor.poll().catch(e => { // eslint-disable-next-line no-console console.warn(`Error while polling search to keep it alive. Considering that it is no longer possible to extend a session.`, e); if (this.isCurrentSession(sessionId)) { this._disableSaveAfterSearchesExpire$.next(true); } })))); }), (0, _operators.repeat)(), (0, _operators.takeUntil)(this.disableSaveAfterSearchesExpire$.pipe((0, _operators.filter)(disable => disable)))); }; return schedulePollSearches(); })).subscribe(() => {})); } /** * If user has access to search sessions * This resolves to `true` in case at least one app allows user to create search session * In this case search session management is available */ hasAccess() { return this.hasAccessToSearchSessions; } /** * Used to track searches within current session * * @param searchDescriptor - uniq object that will be used to as search identifier * @returns {@link TrackSearchHandler} */ trackSearch(searchDescriptor) { this.state.transitions.trackSearch(searchDescriptor, { lastPollingTime: new Date(), isStored: false }); return { complete: () => { this.state.transitions.completeSearch(searchDescriptor); // when search completes and session has just been saved, // trigger polling once again to save search into a session and extend its keep_alive if (this.isStored()) { const search = this.state.selectors.getSearch(searchDescriptor); if (search && !search.searchMeta.isStored) { search.searchDescriptor.poll().catch(e => { // eslint-disable-next-line no-console console.warn(`Failed to extend search after it was completed`, e); }); } } }, error: () => { this.state.transitions.errorSearch(searchDescriptor); }, beforePoll: () => { var _search$searchMeta$is, _search$searchMeta; const search = this.state.selectors.getSearch(searchDescriptor); this.state.transitions.updateSearchMeta(searchDescriptor, { lastPollingTime: new Date() }); return [{ isSearchStored: (_search$searchMeta$is = search === null || search === void 0 ? void 0 : (_search$searchMeta = search.searchMeta) === null || _search$searchMeta === void 0 ? void 0 : _search$searchMeta.isStored) !== null && _search$searchMeta$is !== void 0 ? _search$searchMeta$is : false }, ({ isSearchStored }) => { this.state.transitions.updateSearchMeta(searchDescriptor, { isStored: isSearchStored }); }]; } }; } destroy() { this.subscription.unsubscribe(); this.clear(); this.lastSessionSnapshot = undefined; } /** * Get current session id */ getSessionId() { return this.state.get().sessionId; } /** * Get observable for current session id */ getSession$() { return this.state.state$.pipe((0, _operators.startWith)(this.state.get()), (0, _operators.map)(s => s.sessionId), (0, _operators.distinctUntilChanged)()); } /** * Is current session already saved as SO (send to background) */ isStored() { return this.state.get().isStored; } /** * Is restoring the older saved searches */ isRestore() { return this.state.get().isRestore; } /** * Start a new search session * @returns sessionId */ start() { if (!this.currentApp) throw new Error('this.currentApp is missing'); this.state.transitions.start({ appName: this.currentApp }); return this.getSessionId(); } /** * Restore previously saved search session * @param sessionId */ restore(sessionId) { this.state.transitions.restore(sessionId); this.refreshSearchSessionSavedObject(); } /** * Continue previous search session * Can be used to share a running search session between different apps, so they can reuse search cache * * This is different from {@link restore} as it reuses search session state and search results held in client memory instead of restoring search results from elasticsearch * @param sessionId * * TODO: remove this functionality in favor of separate architecture for client side search cache * that won't interfere with saving search sessions * https://github.com/elastic/kibana/issues/121543 * * @deprecated */ continue(sessionId) { var _this$lastSessionSnap; if (((_this$lastSessionSnap = this.lastSessionSnapshot) === null || _this$lastSessionSnap === void 0 ? void 0 : _this$lastSessionSnap.sessionId) === sessionId) { this.state.set({ ...this.lastSessionSnapshot, // have to change a name, so that current app can cancel a session that it continues appName: this.currentApp, // also have to drop all pending searches which are used to derive client side state of search session indicator, // if we weren't dropping this searches, then we would get into "infinite loading" state when continuing a session that was cleared with pending searches // possible solution to this problem is to refactor session service to support multiple sessions trackedSearches: [], isContinued: true }); this.lastSessionSnapshot = undefined; } else { var _this$lastSessionSnap2; // eslint-disable-next-line no-console console.warn(`Continue search session: last known search session id: "${(_this$lastSessionSnap2 = this.lastSessionSnapshot) === null || _this$lastSessionSnap2 === void 0 ? void 0 : _this$lastSessionSnap2.sessionId}", but received ${sessionId}`); } } /** * Cleans up current state */ clear() { // make sure apps can't clear other apps' sessions const currentSessionApp = this.state.get().appName; if (currentSessionApp && currentSessionApp !== this.currentApp) { // eslint-disable-next-line no-console console.warn(`Skip clearing session "${this.getSessionId()}" because it belongs to a different app. current: "${this.currentApp}", owner: "${currentSessionApp}"`); return; } if (this.getSessionId()) { this.lastSessionSnapshot = this.state.get(); } this.state.transitions.clear(); this.searchSessionInfoProvider = undefined; this.searchSessionIndicatorUiConfig = undefined; } /** * Request a cancellation of on-going search requests within current session */ async cancel() { const isStoredSession = this.isStored(); this.state.get().trackedSearches.filter(s => s.state === _search_session_state.TrackedSearchState.InProgress).forEach(s => { s.searchDescriptor.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) */ async save() { const sessionId = this.getSessionId(); if (!sessionId) throw new Error('No current session'); const currentSessionApp = this.state.get().appName; if (!currentSessionApp) throw new Error('No current session app'); if (!this.hasAccess()) throw new Error('No access to search sessions'); const currentSessionInfoProvider = this.searchSessionInfoProvider; if (!currentSessionInfoProvider) throw new Error('No info provider for current session'); const [name, { initialState, restoreState, id: locatorId }] = await Promise.all([currentSessionInfoProvider.getName(), currentSessionInfoProvider.getLocatorData()]); const formattedName = (0, _session_name_formatter.formatSessionName)(name, { sessionStartTime: this.state.get().startTime, appendStartTime: currentSessionInfoProvider.appendSessionStartTimeToName }); const searchSessionSavedObject = await this.sessionsClient.create({ name: formattedName, appId: currentSessionApp, locatorId, restoreState, initialState, sessionId }); // if we are still interested in this result if (this.isCurrentSession(sessionId)) { this.state.transitions.store(searchSessionSavedObject); // trigger a poll for all the searches that are not yet stored to propagate them into newly created search session saved object and extend their keepAlive const searchesToExtend = this.state.get().trackedSearches.filter(s => s.state !== _search_session_state.TrackedSearchState.Errored && !s.searchMeta.isStored); const extendSearchesPromise = Promise.all(searchesToExtend.map(s => s.searchDescriptor.poll().catch(e => { // eslint-disable-next-line no-console console.warn('Failed to extend search after session was saved', e); }))); // notify all the searches with onSavingSession that session has been saved and saved object has been created // don't wait for the result const searchesWithSavingHandler = this.state.get().trackedSearches.filter(s => s.searchDescriptor.onSavingSession); searchesWithSavingHandler.forEach(s => s.searchDescriptor.onSavingSession({ sessionId, isRestore: this.isRestore(), isStored: this.isStored() }).catch(e => { // eslint-disable-next-line no-console console.warn('Failed to execute "onSavingSession" handler after session was saved', e); })); await extendSearchesPromise; } } /** * Change user-facing name of a current session * Doesn't throw in case of API error but presents a notification toast instead * @param newName - new session name */ async renameCurrentSession(newName) { const sessionId = this.getSessionId(); if (sessionId && this.state.get().isStored) { let renamed = false; try { await this.sessionsClient.rename(sessionId, newName); renamed = true; } catch (e) { var _this$toastService; (_this$toastService = this.toastService) === null || _this$toastService === void 0 ? void 0 : _this$toastService.addError(e, { title: _i18n.i18n.translate('data.searchSessions.sessionService.sessionEditNameError', { defaultMessage: 'Failed to edit name of the search session' }) }); } if (renamed && sessionId === this.getSessionId()) { await this.refreshSearchSessionSavedObject(); } } } /** * Checks if passed sessionId is a current sessionId * @param sessionId */ isCurrentSession(sessionId) { return !!sessionId && this.getSessionId() === sessionId; } /** * Infers search session options for sessionId using current session state * * In case user doesn't has access to `search-session` SO returns null, * meaning that sessionId and other session parameters shouldn't be used when doing searches * * @param sessionId */ getSearchOptions(sessionId) { if (!sessionId) { return null; } // in case user doesn't have permissions to search session, do not forward sessionId to the server // because user most likely also doesn't have access to `search-session` SO if (!this.hasAccessToSearchSessions) { return null; } const isCurrentSession = this.isCurrentSession(sessionId); return { sessionId, isRestore: isCurrentSession ? this.isRestore() : false, isStored: isCurrentSession ? this.isStored() : false }; } /** * Provide an info about current session which is needed for storing a search session. * To opt-into "Search session indicator" UI app has to call {@link enableStorage}. * * @param searchSessionInfoProvider - info provider for saving a search session * @param searchSessionIndicatorUiConfig - config for "Search session indicator" UI */ enableStorage(searchSessionInfoProvider, searchSessionIndicatorUiConfig) { this.searchSessionInfoProvider = { appendSessionStartTimeToName: true, ...searchSessionInfoProvider }; this.searchSessionIndicatorUiConfig = searchSessionIndicatorUiConfig; } /** * If the current app explicitly called {@link enableStorage} and provided all configuration needed * for storing its search sessions */ isSessionStorageReady() { return !!this.searchSessionInfoProvider; } getSearchSessionIndicatorUiConfig() { return { isDisabled: () => ({ disabled: false }), ...this.searchSessionIndicatorUiConfig }; } async refreshSearchSessionSavedObject() { const sessionId = this.getSessionId(); if (sessionId && this.state.get().isStored) { try { const savedObject = await this.sessionsClient.get(sessionId); if (this.getSessionId() === sessionId) { // still interested in this result this.state.transitions.setSearchSessionSavedObject(savedObject); } } catch (e) { var _this$toastService2; (_this$toastService2 = this.toastService) === null || _this$toastService2 === void 0 ? void 0 : _this$toastService2.addError(e, { title: _i18n.i18n.translate('data.searchSessions.sessionService.sessionObjectFetchError', { defaultMessage: 'Failed to fetch search session info' }) }); } } } } exports.SessionService = SessionService;