[Search] Search Sessions with relative time range (#84405)

This commit is contained in:
Anton Dosov 2021-01-12 14:51:04 +01:00 committed by GitHub
parent 02695ef5ad
commit d3303f28bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 672 additions and 276 deletions

View file

@ -20,6 +20,7 @@ export interface DataPublicPluginStart
| [autocomplete](./kibana-plugin-plugins-data-public.datapublicpluginstart.autocomplete.md) | <code>AutocompleteStart</code> | autocomplete service [AutocompleteStart](./kibana-plugin-plugins-data-public.autocompletestart.md) |
| [fieldFormats](./kibana-plugin-plugins-data-public.datapublicpluginstart.fieldformats.md) | <code>FieldFormatsStart</code> | field formats service [FieldFormatsStart](./kibana-plugin-plugins-data-public.fieldformatsstart.md) |
| [indexPatterns](./kibana-plugin-plugins-data-public.datapublicpluginstart.indexpatterns.md) | <code>IndexPatternsContract</code> | index patterns service [IndexPatternsContract](./kibana-plugin-plugins-data-public.indexpatternscontract.md) |
| [nowProvider](./kibana-plugin-plugins-data-public.datapublicpluginstart.nowprovider.md) | <code>NowProviderPublicContract</code> | |
| [query](./kibana-plugin-plugins-data-public.datapublicpluginstart.query.md) | <code>QueryStart</code> | query service [QueryStart](./kibana-plugin-plugins-data-public.querystart.md) |
| [search](./kibana-plugin-plugins-data-public.datapublicpluginstart.search.md) | <code>ISearchStart</code> | search service [ISearchStart](./kibana-plugin-plugins-data-public.isearchstart.md) |
| [ui](./kibana-plugin-plugins-data-public.datapublicpluginstart.ui.md) | <code>DataPublicPluginStartUi</code> | prewired UI components [DataPublicPluginStartUi](./kibana-plugin-plugins-data-public.datapublicpluginstartui.md) |

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; [DataPublicPluginStart](./kibana-plugin-plugins-data-public.datapublicpluginstart.md) &gt; [nowProvider](./kibana-plugin-plugins-data-public.datapublicpluginstart.nowprovider.md)
## DataPublicPluginStart.nowProvider property
<b>Signature:</b>
```typescript
nowProvider: NowProviderPublicContract;
```

View file

@ -173,10 +173,14 @@ export function DashboardApp({
).subscribe(() => refreshDashboardContainer())
);
subscriptions.add(
data.search.session.onRefresh$.subscribe(() => {
merge(
data.search.session.onRefresh$,
data.query.timefilter.timefilter.getAutoRefreshFetch$()
).subscribe(() => {
setLastReloadTime(() => new Date().getTime());
})
);
dashboardStateManager.registerChangeListener(() => {
// we aren't checking dirty state because there are changes the container needs to know about
// that won't make the dashboard "dirty" - like a view mode change.

View file

@ -43,17 +43,22 @@ function getUrlGeneratorState({
data,
getAppState,
getDashboardId,
forceAbsoluteTime, // TODO: not implemented
forceAbsoluteTime,
}: {
data: DataPublicPluginStart;
getAppState: () => DashboardAppState;
getDashboardId: () => string;
/**
* Can force time range from time filter to convert from relative to absolute time range
*/
forceAbsoluteTime: boolean;
}): DashboardUrlGeneratorState {
const appState = getAppState();
return {
dashboardId: getDashboardId(),
timeRange: data.query.timefilter.timefilter.getTime(),
timeRange: forceAbsoluteTime
? data.query.timefilter.timefilter.getAbsoluteTime()
: data.query.timefilter.timefilter.getTime(),
filters: data.query.filterManager.getFilters(),
query: data.query.queryString.formatQuery(appState.query),
savedQuery: appState.savedQuery,

View file

@ -19,7 +19,7 @@
import moment from 'moment';
import sinon from 'sinon';
import { getTime } from './get_time';
import { getTime, getAbsoluteTimeRange } from './get_time';
describe('get_time', () => {
describe('getTime', () => {
@ -90,4 +90,19 @@ describe('get_time', () => {
clock.restore();
});
});
describe('getAbsoluteTimeRange', () => {
test('should forward absolute timerange as is', () => {
const from = '2000-01-01T00:00:00.000Z';
const to = '2000-02-01T00:00:00.000Z';
expect(getAbsoluteTimeRange({ from, to })).toEqual({ from, to });
});
test('should convert relative to absolute', () => {
const clock = sinon.useFakeTimers(moment.utc([2000, 1, 0, 0, 0, 0, 0]).valueOf());
const from = '2000-01-01T00:00:00.000Z';
const to = moment.utc(clock.now).toISOString();
expect(getAbsoluteTimeRange({ from, to: 'now' })).toEqual({ from, to });
clock.restore();
});
});
});

View file

@ -34,6 +34,17 @@ export function calculateBounds(
};
}
export function getAbsoluteTimeRange(
timeRange: TimeRange,
{ forceNow }: { forceNow?: Date } = {}
): TimeRange {
const { min, max } = calculateBounds(timeRange, { forceNow });
return {
from: min ? min.toISOString() : timeRange.from,
to: max ? max.toISOString() : timeRange.to,
};
}
export function getTime(
indexPattern: IIndexPattern | undefined,
timeRange: TimeRange,

View file

@ -62,6 +62,7 @@ export interface EsaggsStartDependencies {
deserializeFieldFormat: FormatFactory;
indexPatterns: IndexPatternsContract;
searchSource: ISearchStartSearchSource;
getNow?: () => Date;
}
/** @internal */
@ -118,7 +119,8 @@ export async function handleEsaggsRequest(
args: Arguments,
params: RequestHandlerParams
): Promise<Datatable> {
const resolvedTimeRange = input?.timeRange && calculateBounds(input.timeRange);
const resolvedTimeRange =
input?.timeRange && calculateBounds(input.timeRange, { forceNow: params.getNow?.() });
const response = await handleRequest(params);

View file

@ -51,6 +51,7 @@ export interface RequestHandlerParams {
searchSourceService: ISearchStartSearchSource;
timeFields?: string[];
timeRange?: TimeRange;
getNow?: () => Date;
}
export const handleRequest = async ({
@ -67,7 +68,9 @@ export const handleRequest = async ({
searchSourceService,
timeFields,
timeRange,
getNow,
}: RequestHandlerParams) => {
const forceNow = getNow?.();
const searchSource = await searchSourceService.create();
searchSource.setField('index', indexPattern);
@ -115,7 +118,7 @@ export const handleRequest = async ({
if (timeRange && allTimeFields.length > 0) {
timeFilterSearchSource.setField('filter', () => {
return allTimeFields
.map((fieldName) => getTime(indexPattern, timeRange, { fieldName }))
.map((fieldName) => getTime(indexPattern, timeRange, { fieldName, forceNow }))
.filter(isRangeFilter);
});
}
@ -183,7 +186,7 @@ export const handleRequest = async ({
}
}
const parsedTimeRange = timeRange ? calculateBounds(timeRange) : null;
const parsedTimeRange = timeRange ? calculateBounds(timeRange, { forceNow }) : null;
const tabifyParams = {
metricsAtAllLevels,
partialRows,

View file

@ -22,6 +22,7 @@ import { fieldFormatsServiceMock } from './field_formats/mocks';
import { searchServiceMock } from './search/mocks';
import { queryServiceMock } from './query/mocks';
import { AutocompleteStart, AutocompleteSetup } from './autocomplete';
import { createNowProviderMock } from './now_provider/mocks';
export type Setup = jest.Mocked<ReturnType<Plugin['setup']>>;
export type Start = jest.Mocked<ReturnType<Plugin['start']>>;
@ -76,6 +77,7 @@ const createStartContract = (): Start => {
get: jest.fn().mockReturnValue(Promise.resolve({})),
clearCache: jest.fn(),
} as unknown) as IndexPatternsContract,
nowProvider: createNowProviderMock(),
};
};

View file

@ -0,0 +1,24 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export {
NowProvider,
NowProviderInternalContract,
NowProviderPublicContract,
} from './now_provider';

View file

@ -0,0 +1,50 @@
/*
* 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 { getForceNowFromUrl } from './get_force_now_from_url';
const originalLocation = window.location;
afterAll(() => {
window.location = originalLocation;
});
function mockLocation(url: string) {
// @ts-ignore
delete window.location;
// @ts-ignore
window.location = new URL(url);
}
test('should get force now from URL', () => {
const dateString = '1999-01-01T00:00:00.000Z';
mockLocation(`https://elastic.co/?forceNow=${dateString}`);
expect(getForceNowFromUrl()).toEqual(new Date(dateString));
});
test('should throw if force now is invalid', () => {
const dateString = 'invalid-date';
mockLocation(`https://elastic.co/?forceNow=${dateString}`);
expect(() => getForceNowFromUrl()).toThrowError();
});
test('should return undefined if no forceNow', () => {
mockLocation(`https://elastic.co/`);
expect(getForceNowFromUrl()).toBe(undefined);
});

View file

@ -16,10 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
import { parse } from 'query-string';
/** @internal */
export function parseQueryString() {
export function getForceNowFromUrl(): Date | undefined {
const forceNow = parseQueryString().forceNow as string;
if (!forceNow) {
return;
}
const ts = Date.parse(forceNow);
if (isNaN(ts)) {
throw new Error(`forceNow query parameter, ${forceNow}, can't be parsed by Date.parse`);
}
return new Date(ts);
}
/** @internal */
function parseQueryString() {
// window.location.search is an empty string
// get search from href
const hrefSplit = window.location.href.split('?');

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { getForceNowFromUrl } from './get_force_now_from_url';

View file

@ -17,18 +17,12 @@
* under the License.
*/
import { parseQueryString } from './parse_querystring';
import { NowProviderInternalContract } from './now_provider';
/** @internal */
export function getForceNow() {
const forceNow = parseQueryString().forceNow as string;
if (!forceNow) {
return;
}
const ticks = Date.parse(forceNow);
if (isNaN(ticks)) {
throw new Error(`forceNow query parameter, ${forceNow}, can't be parsed by Date.parse`);
}
return new Date(ticks);
}
export const createNowProviderMock = (): jest.Mocked<NowProviderInternalContract> => {
return {
get: jest.fn(() => new Date()),
set: jest.fn(),
reset: jest.fn(),
};
};

View file

@ -0,0 +1,55 @@
/*
* 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 { NowProvider, NowProviderInternalContract } from './now_provider';
let mockDateFromUrl: undefined | Date;
let nowProvider: NowProviderInternalContract;
jest.mock('./lib', () => ({
// @ts-ignore
...jest.requireActual('./lib'),
getForceNowFromUrl: () => mockDateFromUrl,
}));
beforeEach(() => {
nowProvider = new NowProvider();
});
afterEach(() => {
mockDateFromUrl = undefined;
});
test('should return Date.now() by default', async () => {
const now = Date.now();
await new Promise((r) => setTimeout(r, 10));
expect(nowProvider.get().getTime()).toBeGreaterThan(now);
});
test('should forceNow from URL', async () => {
mockDateFromUrl = new Date('1999-01-01T00:00:00.000Z');
nowProvider = new NowProvider();
expect(nowProvider.get()).toEqual(mockDateFromUrl);
});
test('should forceNow from URL if custom now is set', async () => {
mockDateFromUrl = new Date('1999-01-01T00:00:00.000Z');
nowProvider = new NowProvider();
nowProvider.set(new Date());
expect(nowProvider.get()).toEqual(mockDateFromUrl);
});

View file

@ -0,0 +1,50 @@
/*
* 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 { PublicMethodsOf } from '@kbn/utility-types';
import { getForceNowFromUrl } from './lib';
export type NowProviderInternalContract = PublicMethodsOf<NowProvider>;
export type NowProviderPublicContract = Pick<NowProviderInternalContract, 'get'>;
/**
* Used to synchronize time between parallel searches with relative time range that rely on `now`.
*/
export class NowProvider {
// TODO: service shouldn't access params in the URL
// instead it should be handled by apps
private readonly nowFromUrl = getForceNowFromUrl();
private now?: Date;
constructor() {}
get(): Date {
if (this.nowFromUrl) return this.nowFromUrl; // now forced from URL always takes precedence
if (this.now) return this.now;
return new Date();
}
set(now: Date) {
this.now = now;
}
reset() {
this.now = undefined;
}
}

View file

@ -61,6 +61,7 @@ import { SavedObjectsClientPublicToCommon } from './index_patterns';
import { getIndexPatternLoad } from './index_patterns/expressions';
import { UsageCollectionSetup } from '../../usage_collection/public';
import { getTableViewDescription } from './utils/table_inspector_view';
import { NowProvider, NowProviderInternalContract } from './now_provider';
export class DataPublicPlugin
implements
@ -76,6 +77,7 @@ export class DataPublicPlugin
private readonly queryService: QueryService;
private readonly storage: IStorageWrapper;
private usageCollection: UsageCollectionSetup | undefined;
private readonly nowProvider: NowProviderInternalContract;
constructor(initializerContext: PluginInitializerContext<ConfigSchema>) {
this.searchService = new SearchService(initializerContext);
@ -83,6 +85,7 @@ export class DataPublicPlugin
this.fieldFormatsService = new FieldFormatsService();
this.autocomplete = new AutocompleteService(initializerContext);
this.storage = new Storage(window.localStorage);
this.nowProvider = new NowProvider();
}
public setup(
@ -95,9 +98,17 @@ export class DataPublicPlugin
this.usageCollection = usageCollection;
const searchService = this.searchService.setup(core, {
bfetch,
usageCollection,
expressions,
nowProvider: this.nowProvider,
});
const queryService = this.queryService.setup({
uiSettings: core.uiSettings,
storage: this.storage,
nowProvider: this.nowProvider,
});
uiActions.registerTrigger(applyFilterTrigger);
@ -120,12 +131,6 @@ export class DataPublicPlugin
}))
);
const searchService = this.searchService.setup(core, {
bfetch,
usageCollection,
expressions,
});
inspector.registerView(
getTableViewDescription(() => ({
uiActions: startServices().plugins.uiActions,
@ -195,6 +200,7 @@ export class DataPublicPlugin
indexPatterns,
query,
search,
nowProvider: this.nowProvider,
};
const SearchBar = createSearchBar({

View file

@ -580,6 +580,10 @@ export interface DataPublicPluginStart {
autocomplete: AutocompleteStart;
fieldFormats: FieldFormatsStart;
indexPatterns: IndexPatternsContract;
// Warning: (ae-forgotten-export) The symbol "NowProviderPublicContract" needs to be exported by the entry point index.d.ts
//
// (undocumented)
nowProvider: NowProviderPublicContract;
query: QueryStart;
search: ISearchStart;
ui: DataPublicPluginStartUi;
@ -2620,7 +2624,7 @@ export const UI_SETTINGS: {
// 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:50:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/search/session/session_service.ts:51: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

@ -28,6 +28,7 @@ import { createQueryStateObservable } from './state_sync/create_global_query_obs
import { QueryStringManager, QueryStringContract } from './query_string';
import { buildEsQuery, getEsQueryConfig } from '../../common';
import { getUiSettings } from '../services';
import { NowProviderInternalContract } from '../now_provider';
import { IndexPattern } from '..';
/**
@ -38,6 +39,7 @@ import { IndexPattern } from '..';
interface QueryServiceSetupDependencies {
storage: IStorageWrapper;
uiSettings: IUiSettingsClient;
nowProvider: NowProviderInternalContract;
}
interface QueryServiceStartDependencies {
@ -53,10 +55,10 @@ export class QueryService {
state$!: ReturnType<typeof createQueryStateObservable>;
public setup({ storage, uiSettings }: QueryServiceSetupDependencies) {
public setup({ storage, uiSettings, nowProvider }: QueryServiceSetupDependencies) {
this.filterManager = new FilterManager(uiSettings);
const timefilterService = new TimefilterService();
const timefilterService = new TimefilterService(nowProvider);
this.timefilter = timefilterService.setup({
uiSettings,
storage,

View file

@ -28,6 +28,7 @@ import { StubBrowserStorage } from '@kbn/test/jest';
import { connectToQueryState } from './connect_to_query_state';
import { TimefilterContract } from '../timefilter';
import { QueryState } from './types';
import { createNowProviderMock } from '../../now_provider/mocks';
const connectToQueryGlobalState = (query: QueryStart, state: BaseStateContainer<QueryState>) =>
connectToQueryState(query, state, {
@ -79,6 +80,7 @@ describe('connect_to_global_state', () => {
queryService.setup({
uiSettings: setupMock.uiSettings,
storage: new Storage(new StubBrowserStorage()),
nowProvider: createNowProviderMock(),
});
queryServiceStart = queryService.start({
uiSettings: setupMock.uiSettings,
@ -312,6 +314,7 @@ describe('connect_to_app_state', () => {
queryService.setup({
uiSettings: setupMock.uiSettings,
storage: new Storage(new StubBrowserStorage()),
nowProvider: createNowProviderMock(),
});
queryServiceStart = queryService.start({
uiSettings: setupMock.uiSettings,
@ -490,6 +493,7 @@ describe('filters with different state', () => {
queryService.setup({
uiSettings: setupMock.uiSettings,
storage: new Storage(new StubBrowserStorage()),
nowProvider: createNowProviderMock(),
});
queryServiceStart = queryService.start({
uiSettings: setupMock.uiSettings,

View file

@ -33,6 +33,7 @@ import { StubBrowserStorage } from '@kbn/test/jest';
import { TimefilterContract } from '../timefilter';
import { syncQueryStateWithUrl } from './sync_state_with_url';
import { QueryState } from './types';
import { createNowProviderMock } from '../../now_provider/mocks';
const setupMock = coreMock.createSetup();
const startMock = coreMock.createStart();
@ -73,6 +74,7 @@ describe('sync_query_state_with_url', () => {
queryService.setup({
uiSettings: setupMock.uiSettings,
storage: new Storage(new StubBrowserStorage()),
nowProvider: createNowProviderMock(),
});
queryServiceStart = queryService.start({
uiSettings: startMock.uiSettings,

View file

@ -19,21 +19,12 @@
jest.useFakeTimers();
jest.mock('./lib/parse_querystring', () => ({
parseQueryString: () => {
return {
// Can not access local variable from within a mock
// @ts-ignore
forceNow: global.nowTime,
};
},
}));
import sinon from 'sinon';
import moment from 'moment';
import { Timefilter } from './timefilter';
import { Subscription } from 'rxjs';
import { TimeRange, RefreshInterval } from '../../../common';
import { createNowProviderMock } from '../../now_provider/mocks';
import { timefilterServiceMock } from './timefilter_service.mock';
const timefilterSetupMock = timefilterServiceMock.createSetupContract();
@ -42,16 +33,16 @@ const timefilterConfig = {
timeDefaults: { from: 'now-15m', to: 'now' },
refreshIntervalDefaults: { pause: false, value: 0 },
};
const timefilter = new Timefilter(timefilterConfig, timefilterSetupMock.history);
const nowProviderMock = createNowProviderMock();
const timefilter = new Timefilter(timefilterConfig, timefilterSetupMock.history, nowProviderMock);
function stubNowTime(nowTime: any) {
// @ts-ignore
global.nowTime = nowTime;
nowProviderMock.get.mockImplementation(() => (nowTime ? new Date(nowTime) : new Date()));
}
function clearNowTimeStub() {
// @ts-ignore
delete global.nowTime;
nowProviderMock.get.mockReset();
}
test('isTimeTouched is initially set to false', () => {

View file

@ -22,10 +22,11 @@ import { Subject, BehaviorSubject } from 'rxjs';
import moment from 'moment';
import { PublicMethodsOf } from '@kbn/utility-types';
import { areRefreshIntervalsDifferent, areTimeRangesDifferent } from './lib/diff_time_picker_vals';
import { getForceNow } from './lib/get_force_now';
import { TimefilterConfig, InputTimeRange, TimeRangeBounds } from './types';
import { NowProviderInternalContract } from '../../now_provider';
import {
calculateBounds,
getAbsoluteTimeRange,
getTime,
IIndexPattern,
RefreshInterval,
@ -60,7 +61,11 @@ export class Timefilter {
private readonly timeDefaults: TimeRange;
private readonly refreshIntervalDefaults: RefreshInterval;
constructor(config: TimefilterConfig, timeHistory: TimeHistoryContract) {
constructor(
config: TimefilterConfig,
timeHistory: TimeHistoryContract,
private readonly nowProvider: NowProviderInternalContract
) {
this._history = timeHistory;
this.timeDefaults = config.timeDefaults;
this.refreshIntervalDefaults = config.refreshIntervalDefaults;
@ -109,6 +114,13 @@ export class Timefilter {
};
};
/**
* Same as {@link getTime}, but also converts relative time range to absolute time range
*/
public getAbsoluteTime() {
return getAbsoluteTimeRange(this._time, { forceNow: this.nowProvider.get() });
}
/**
* Updates timefilter time.
* Emits 'timeUpdate' and 'fetch' events when time changes
@ -177,7 +189,7 @@ export class Timefilter {
public createFilter = (indexPattern: IIndexPattern, timeRange?: TimeRange) => {
return getTime(indexPattern, timeRange ? timeRange : this._time, {
forceNow: this.getForceNow(),
forceNow: this.nowProvider.get(),
});
};
@ -186,7 +198,7 @@ export class Timefilter {
}
public calculateBounds(timeRange: TimeRange): TimeRangeBounds {
return calculateBounds(timeRange, { forceNow: this.getForceNow() });
return calculateBounds(timeRange, { forceNow: this.nowProvider.get() });
}
public getActiveBounds(): TimeRangeBounds | undefined {
@ -234,10 +246,6 @@ export class Timefilter {
public getRefreshIntervalDefaults(): RefreshInterval {
return _.cloneDeep(this.refreshIntervalDefaults);
}
private getForceNow = () => {
return getForceNow();
};
}
export type TimefilterContract = PublicMethodsOf<Timefilter>;

View file

@ -46,6 +46,7 @@ const createSetupContractMock = () => {
createFilter: jest.fn(),
getRefreshIntervalDefaults: jest.fn(),
getTimeDefaults: jest.fn(),
getAbsoluteTime: jest.fn(),
};
const historyMock: jest.Mocked<TimeHistoryContract> = {

View file

@ -21,6 +21,7 @@ import { IUiSettingsClient } from 'src/core/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { TimeHistory, Timefilter, TimeHistoryContract, TimefilterContract } from './index';
import { UI_SETTINGS } from '../../../common';
import { NowProviderInternalContract } from '../../now_provider';
/**
* Filter Service
@ -33,13 +34,15 @@ export interface TimeFilterServiceDependencies {
}
export class TimefilterService {
constructor(private readonly nowProvider: NowProviderInternalContract) {}
public setup({ uiSettings, storage }: TimeFilterServiceDependencies): TimefilterSetup {
const timefilterConfig = {
timeDefaults: uiSettings.get(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS),
refreshIntervalDefaults: uiSettings.get(UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS),
};
const history = new TimeHistory(storage);
const timefilter = new Timefilter(timefilterConfig, history);
const timefilter = new Timefilter(timefilterConfig, history, this.nowProvider);
return {
timefilter,

View file

@ -31,6 +31,7 @@ import {
AggsStartDependencies,
createGetConfig,
} from './aggs_service';
import { createNowProviderMock } from '../../now_provider/mocks';
const { uiSettings } = coreMock.createSetup();
@ -44,6 +45,7 @@ describe('AggsService - public', () => {
setupDeps = {
registerFunction: expressionsPluginMock.createSetupContract().registerFunction,
uiSettings,
nowProvider: createNowProviderMock(),
};
startDeps = {
fieldFormats: fieldFormatsServiceMock.createStartContract(),

View file

@ -22,7 +22,6 @@ import { Subscription } from 'rxjs';
import { IUiSettingsClient } from 'src/core/public';
import { ExpressionsServiceSetup } from 'src/plugins/expressions/common';
import { FieldFormatsStart } from '../../field_formats';
import { getForceNow } from '../../query/timefilter/lib/get_force_now';
import { calculateBounds, TimeRange } from '../../../common';
import {
aggsRequiredUiSettings,
@ -33,6 +32,7 @@ import {
} from '../../../common/search/aggs';
import { AggsSetup, AggsStart } from './types';
import { IndexPatternsContract } from '../../index_patterns';
import { NowProviderInternalContract } from '../../now_provider';
/**
* Aggs needs synchronous access to specific uiSettings. Since settings can change
@ -63,6 +63,7 @@ export function createGetConfig(
export interface AggsSetupDependencies {
registerFunction: ExpressionsServiceSetup['registerFunction'];
uiSettings: IUiSettingsClient;
nowProvider: NowProviderInternalContract;
}
/** @internal */
@ -82,15 +83,17 @@ export class AggsService {
private readonly initializedAggTypes = new Map();
private getConfig?: AggsCommonStartDependencies['getConfig'];
private subscriptions: Subscription[] = [];
private nowProvider!: NowProviderInternalContract;
/**
* getForceNow uses window.location, so we must have a separate implementation
* NowGetter uses window.location, so we must have a separate implementation
* of calculateBounds on the client and the server.
*/
private calculateBounds = (timeRange: TimeRange) =>
calculateBounds(timeRange, { forceNow: getForceNow() });
calculateBounds(timeRange, { forceNow: this.nowProvider.get() });
public setup({ registerFunction, uiSettings }: AggsSetupDependencies): AggsSetup {
public setup({ registerFunction, uiSettings, nowProvider }: AggsSetupDependencies): AggsSetup {
this.nowProvider = nowProvider;
this.getConfig = createGetConfig(uiSettings, aggsRequiredUiSettings, this.subscriptions);
return this.aggsCommonService.setup({ registerFunction });

View file

@ -53,6 +53,7 @@ export function getFunctionDefinition({
deserializeFieldFormat,
indexPatterns,
searchSource,
getNow,
} = await getStartDependencies();
const indexPattern = await indexPatterns.create(args.index.value, true);
@ -75,6 +76,7 @@ export function getFunctionDefinition({
searchSourceService: searchSource,
timeFields: args.timeFields,
timeRange: get(input, 'timeRange', undefined),
getNow,
});
},
});
@ -102,12 +104,13 @@ export function getEsaggs({
return getFunctionDefinition({
getStartDependencies: async () => {
const [, , self] = await getStartServices();
const { fieldFormats, indexPatterns, search } = self;
const { fieldFormats, indexPatterns, search, nowProvider } = self;
return {
aggs: search.aggs,
deserializeFieldFormat: fieldFormats.deserialize.bind(fieldFormats),
indexPatterns,
searchSource: search.searchSource,
getNow: () => nowProvider.get(),
};
},
});

View file

@ -53,12 +53,14 @@ import {
} from '../../common/search/aggs/buckets/shard_delay';
import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn';
import { DataPublicPluginStart, DataStartDependencies } from '../types';
import { NowProviderInternalContract } from '../now_provider';
/** @internal */
export interface SearchServiceSetupDependencies {
bfetch: BfetchPublicSetup;
expressions: ExpressionsSetup;
usageCollection?: UsageCollectionSetup;
nowProvider: NowProviderInternalContract;
}
/** @internal */
@ -79,7 +81,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
public setup(
{ http, getStartServices, notifications, uiSettings }: CoreSetup,
{ bfetch, expressions, usageCollection }: SearchServiceSetupDependencies
{ bfetch, expressions, usageCollection, nowProvider }: SearchServiceSetupDependencies
): ISearchSetup {
this.usageCollector = createUsageCollector(getStartServices, usageCollection);
@ -87,7 +89,8 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
this.sessionService = new SessionService(
this.initializerContext,
getStartServices,
this.sessionsClient
this.sessionsClient,
nowProvider
);
/**
* A global object that intercepts all searches and provides convenience methods for cancelling
@ -118,6 +121,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
const aggs = this.aggsService.setup({
registerFunction: expressions.registerFunction,
uiSettings,
nowProvider,
});
if (this.initializerContext.config.get().search.aggs.shardDelay.enabled) {

View file

@ -31,6 +31,7 @@ describe('Session state container', () => {
state.transitions.start();
expect(state.selectors.getState()).toBe(SearchSessionState.None);
expect(state.get().sessionId).not.toBeUndefined();
expect(state.get().startTime).not.toBeUndefined();
});
test('track', () => {
@ -56,6 +57,7 @@ describe('Session state container', () => {
state.transitions.clear();
expect(state.selectors.getState()).toBe(SearchSessionState.None);
expect(state.get().sessionId).toBeUndefined();
expect(state.get().startTime).toBeUndefined();
});
test('cancel', () => {

View file

@ -105,6 +105,11 @@ export interface SessionStateInternal<SearchDescriptor = unknown> {
* If user has explicitly canceled search requests
*/
isCanceled: boolean;
/**
* Start time of current session
*/
startTime?: Date;
}
const createSessionDefaultState: <
@ -132,7 +137,11 @@ export interface SessionPureTransitions<
}
export const sessionPureTransitions: SessionPureTransitions = {
start: (state) => () => ({ ...createSessionDefaultState(), sessionId: uuid.v4() }),
start: (state) => () => ({
...createSessionDefaultState(),
sessionId: uuid.v4(),
startTime: new Date(),
}),
restore: (state) => (sessionId: string) => ({
...createSessionDefaultState(),
sessionId,
@ -216,6 +225,7 @@ export const createSessionStateContainer = <SearchDescriptor = unknown>(
): {
stateContainer: SessionStateContainer<SearchDescriptor>;
sessionState$: Observable<SearchSessionState>;
sessionStartTime$: Observable<Date | undefined>;
} => {
const stateContainer = createStateContainer(
createSessionDefaultState(),
@ -229,8 +239,16 @@ export const createSessionStateContainer = <SearchDescriptor = unknown>(
distinctUntilChanged(),
shareReplay(1)
);
const sessionStartTime$: Observable<Date | undefined> = stateContainer.state$.pipe(
map(() => stateContainer.get().startTime),
distinctUntilChanged(),
shareReplay(1)
);
return {
stateContainer,
sessionState$,
sessionStartTime$,
};
};

View file

@ -23,17 +23,22 @@ import { take, toArray } from 'rxjs/operators';
import { getSessionsClientMock } from './mocks';
import { BehaviorSubject } from 'rxjs';
import { SearchSessionState } from './search_session_state';
import { createNowProviderMock } from '../../now_provider/mocks';
import { NowProviderInternalContract } from '../../now_provider';
describe('Session service', () => {
let sessionService: ISessionService;
let state$: BehaviorSubject<SearchSessionState>;
let nowProvider: jest.Mocked<NowProviderInternalContract>;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext();
nowProvider = createNowProviderMock();
sessionService = new SessionService(
initializerContext,
coreMock.createSetup().getStartServices,
getSessionsClientMock(),
nowProvider,
{ freezeState: false } // needed to use mocks inside state container
);
state$ = new BehaviorSubject<SearchSessionState>(SearchSessionState.None);
@ -44,8 +49,10 @@ describe('Session service', () => {
it('Creates and clears a session', async () => {
sessionService.start();
expect(sessionService.getSessionId()).not.toBeUndefined();
expect(nowProvider.set).toHaveBeenCalled();
sessionService.clear();
expect(sessionService.getSessionId()).toBeUndefined();
expect(nowProvider.reset).toHaveBeenCalled();
});
it('Restores a session', async () => {

View file

@ -29,6 +29,7 @@ import {
SessionStateContainer,
} from './search_session_state';
import { ISessionsClient } from './sessions_client';
import { NowProviderInternalContract } from '../../now_provider';
export type ISessionService = PublicContract<SessionService>;
@ -60,40 +61,54 @@ export class SessionService {
private readonly state: SessionStateContainer<TrackSearchDescriptor>;
private searchSessionInfoProvider?: SearchSessionInfoProvider;
private appChangeSubscription$?: Subscription;
private subscription = new Subscription();
private curApp?: string;
constructor(
initializerContext: PluginInitializerContext<ConfigSchema>,
getStartServices: StartServicesAccessor,
private readonly sessionsClient: ISessionsClient,
private readonly nowProvider: NowProviderInternalContract,
{ freezeState = true }: { freezeState: boolean } = { freezeState: true }
) {
const { stateContainer, sessionState$ } = createSessionStateContainer<TrackSearchDescriptor>({
const {
stateContainer,
sessionState$,
sessionStartTime$,
} = createSessionStateContainer<TrackSearchDescriptor>({
freeze: freezeState,
});
this.state$ = sessionState$;
this.state = stateContainer;
this.subscription.add(
sessionStartTime$.subscribe((startTime) => {
if (startTime) this.nowProvider.set(startTime);
else this.nowProvider.reset();
})
);
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.subscription.add(
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;
});
this.curApp = appName;
})
);
});
}
@ -122,9 +137,7 @@ export class SessionService {
}
public destroy() {
if (this.appChangeSubscription$) {
this.appChangeSubscription$.unsubscribe();
}
this.subscription.unsubscribe();
this.clear();
}

View file

@ -32,6 +32,7 @@ import { IndexPatternsContract } from './index_patterns';
import { IndexPatternSelectProps, StatefulSearchBarProps } from './ui';
import { UsageCollectionSetup } from '../../usage_collection/public';
import { Setup as InspectorSetup } from '../../inspector/public';
import { NowProviderPublicContract } from './now_provider';
export interface DataPublicPluginEnhancements {
search: SearchEnhancements;
@ -118,6 +119,8 @@ export interface DataPublicPluginStart {
* {@link DataPublicPluginStartUi}
*/
ui: DataPublicPluginStartUi;
nowProvider: NowProviderPublicContract;
}
export interface IDataPluginServices extends Partial<CoreStart> {

View file

@ -282,11 +282,14 @@ function createUrlGeneratorState({
appStateContainer,
data,
getSavedSearchId,
forceAbsoluteTime, // TODO: not implemented
forceAbsoluteTime,
}: {
appStateContainer: StateContainer<AppState>;
data: DataPublicPluginStart;
getSavedSearchId: () => string | undefined;
/**
* Can force time range from time filter to convert from relative to absolute time range
*/
forceAbsoluteTime: boolean;
}): DiscoverUrlGeneratorState {
const appState = appStateContainer.get();
@ -295,7 +298,9 @@ function createUrlGeneratorState({
indexPatternId: appState.index,
query: appState.query,
savedSearchId: getSavedSearchId(),
timeRange: data.query.timefilter.timefilter.getTime(), // TODO: handle relative time range
timeRange: forceAbsoluteTime
? data.query.timefilter.timefilter.getAbsoluteTime()
: data.query.timefilter.timefilter.getTime(),
searchSessionId: data.search.session.getSessionId(),
columns: appState.columns,
sort: appState.sort,

View file

@ -29,7 +29,6 @@ import {
Filter,
TimeRange,
FilterManager,
getTime,
Query,
IFieldType,
} from '../../../../data/public';
@ -98,7 +97,6 @@ export class SearchEmbeddable
private panelTitle: string = '';
private filtersSearchSource?: ISearchSource;
private searchInstance?: JQLite;
private autoRefreshFetchSubscription?: Subscription;
private subscription?: Subscription;
public readonly type = SEARCH_EMBEDDABLE_TYPE;
private filterManager: FilterManager;
@ -148,10 +146,6 @@ export class SearchEmbeddable
};
this.initializeSearchScope();
this.autoRefreshFetchSubscription = this.services.timefilter
.getAutoRefreshFetch$()
.subscribe(this.fetch);
this.subscription = this.getUpdated$().subscribe(() => {
this.panelTitle = this.output.title || '';
@ -199,9 +193,7 @@ export class SearchEmbeddable
if (this.subscription) {
this.subscription.unsubscribe();
}
if (this.autoRefreshFetchSubscription) {
this.autoRefreshFetchSubscription.unsubscribe();
}
if (this.abortController) this.abortController.abort();
}
@ -224,7 +216,7 @@ export class SearchEmbeddable
const timeRangeSearchSource = searchSource.create();
timeRangeSearchSource.setField('filter', () => {
if (!this.searchScope || !this.input.timeRange) return;
return getTime(indexPattern, this.input.timeRange);
return this.services.timefilter.createFilter(indexPattern, this.input.timeRange);
});
this.filtersSearchSource = searchSource.create();

View file

@ -108,7 +108,6 @@ export class VisualizeEmbeddable
private vis: Vis;
private domNode: any;
public readonly type = VISUALIZE_EMBEDDABLE_TYPE;
private autoRefreshFetchSubscription: Subscription;
private abortController?: AbortController;
private readonly deps: VisualizeEmbeddableFactoryDeps;
private readonly inspectorAdapters?: Adapters;
@ -152,10 +151,6 @@ export class VisualizeEmbeddable
this.attributeService = attributeService;
this.savedVisualizationsLoader = savedVisualizationsLoader;
this.autoRefreshFetchSubscription = timefilter
.getAutoRefreshFetch$()
.subscribe(this.updateHandler.bind(this));
this.subscriptions.push(
this.getUpdated$().subscribe(() => {
const isDirty = this.handleChanges();
@ -368,7 +363,6 @@ export class VisualizeEmbeddable
this.handler.destroy();
this.handler.getElement().remove();
}
this.autoRefreshFetchSubscription.unsubscribe();
}
public reload = () => {

View file

@ -190,6 +190,17 @@ const TopNav = ({
}
}, [vis.params, vis.type, services.data.indexPatterns, vis.data.indexPattern]);
useEffect(() => {
const autoRefreshFetchSub = services.data.query.timefilter.timefilter
.getAutoRefreshFetch$()
.subscribe(() => {
visInstance.embeddableHandler.reload();
});
return () => {
autoRefreshFetchSub.unsubscribe();
};
}, [services.data.query.timefilter.timefilter, visInstance.embeddableHandler]);
return isChromeVisible ? (
/**
* Most visualizations have all search bar components enabled.

View file

@ -33,6 +33,7 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft
const log = getService('log');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['header', 'common']);
const inspector = getService('inspector');
return new (class DashboardPanelActions {
async findContextMenu(parent?: WebElementWrapper) {
@ -163,6 +164,16 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft
await this.openInspector(header);
}
async getSearchSessionIdByTitle(title: string) {
await this.openInspectorByTitle(title);
await inspector.openInspectorRequestsView();
const searchSessionId = await (
await testSubjects.find('inspectorRequestSearchSessionId')
).getAttribute('data-search-session-id');
await inspector.close();
return searchSessionId;
}
async openInspector(parent?: WebElementWrapper) {
await this.openContextMenu(parent);
const exists = await testSubjects.exists(OPEN_INSPECTOR_TEST_SUBJ);

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Subject } from 'rxjs';
import {
Embeddable,
LensByValueInput,
@ -13,13 +12,7 @@ import {
LensEmbeddableInput,
} from './embeddable';
import { ReactExpressionRendererProps } from 'src/plugins/expressions/public';
import {
Query,
TimeRange,
Filter,
TimefilterContract,
IndexPatternsContract,
} from 'src/plugins/data/public';
import { Query, TimeRange, Filter, IndexPatternsContract } from 'src/plugins/data/public';
import { Document } from '../../persistence';
import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks';
import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/plugins/visualizations/public/embeddable';
@ -536,49 +529,4 @@ describe('embeddable', () => {
expect(expressionRenderer).toHaveBeenCalledTimes(1);
});
it('should re-render on auto refresh fetch observable', async () => {
const timeRange: TimeRange = { from: 'now-15d', to: 'now' };
const query: Query = { language: 'kquery', query: '' };
const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: true } }];
const autoRefreshFetchSubject = new Subject();
const timefilter = ({
getAutoRefreshFetch$: () => autoRefreshFetchSubject.asObservable(),
} as unknown) as TimefilterContract;
const embeddable = new Embeddable(
{
timefilter,
attributeService,
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
getTrigger,
documentToExpression: () =>
Promise.resolve({
type: 'expression',
chain: [
{ type: 'function', function: 'my', arguments: {} },
{ type: 'function', function: 'expression', arguments: {} },
],
}),
},
{ id: '123', timeRange, query, filters } as LensEmbeddableInput
);
await embeddable.initializeSavedVis({
id: '123',
timeRange,
query,
filters,
} as LensEmbeddableInput);
embeddable.render(mountpoint);
act(() => {
autoRefreshFetchSubject.next();
});
expect(expressionRenderer).toHaveBeenCalledTimes(2);
});
});

View file

@ -96,7 +96,6 @@ export class Embeddable
private expression: string | undefined | null;
private domNode: HTMLElement | Element | undefined;
private subscription: Subscription;
private autoRefreshFetchSubscription: Subscription;
private isInitialized = false;
private activeData: Partial<DefaultInspectorAdapters> | undefined;
@ -127,10 +126,6 @@ export class Embeddable
this.onContainerStateChanged(this.input)
);
this.autoRefreshFetchSubscription = deps.timefilter
.getAutoRefreshFetch$()
.subscribe(this.reload.bind(this));
const input$ = this.getInput$();
// Lens embeddable does not re-render when embeddable input changes in
@ -450,6 +445,5 @@ export class Embeddable
if (this.subscription) {
this.subscription.unsubscribe();
}
this.autoRefreshFetchSubscription.unsubscribe();
}
}

View file

@ -4,36 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { TimefilterContract } from '../../../../../../../../src/plugins/data/public';
import { Observable } from 'rxjs';
import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks';
/**
* Copy from {@link '../../../../../../../../src/plugins/data/public/query/timefilter/timefilter_service.mock'}
*/
const timefilterMock: jest.Mocked<TimefilterContract> = {
isAutoRefreshSelectorEnabled: jest.fn(),
isTimeRangeSelectorEnabled: jest.fn(),
isTimeTouched: jest.fn(),
getEnabledUpdated$: jest.fn(),
getTimeUpdate$: jest.fn(),
getRefreshIntervalUpdate$: jest.fn(),
getAutoRefreshFetch$: jest.fn(() => new Observable<unknown>()),
getFetch$: jest.fn(),
getTime: jest.fn(),
setTime: jest.fn(),
setRefreshInterval: jest.fn(),
getRefreshInterval: jest.fn(),
getActiveBounds: jest.fn(),
disableAutoRefreshSelector: jest.fn(),
disableTimeRangeSelector: jest.fn(),
enableAutoRefreshSelector: jest.fn(),
enableTimeRangeSelector: jest.fn(),
getBounds: jest.fn(),
calculateBounds: jest.fn(),
createFilter: jest.fn(),
getRefreshIntervalDefaults: jest.fn(),
getTimeDefaults: jest.fn(),
};
const timefilterMock = dataPluginMock.createStartContract().query.timefilter.timefilter;
export const useTimefilter = jest.fn(() => {
return timefilterMock;

View file

@ -6,17 +6,14 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../ftr_provider_context';
import { getSearchSessionIdByPanelProvider } from './get_search_session_id_by_panel';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const es = getService('es');
const testSubjects = getService('testSubjects');
const log = getService('log');
const PageObjects = getPageObjects(['common', 'header', 'dashboard', 'visChart']);
const getSearchSessionIdByPanel = getSearchSessionIdByPanelProvider(getService);
const dashboardPanelActions = getService('dashboardPanelActions');
const queryBar = getService('queryBar');
const browser = getService('browser');
const sendToBackground = getService('sendToBackground');
describe('dashboard with async search', () => {
before(async function () {
@ -63,74 +60,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// but only single error toast because searches are grouped
expect((await testSubjects.findAll('searchTimeoutError')).length).to.be(1);
const panel1SessionId1 = await getSearchSessionIdByPanel('Sum of Bytes by Extension');
const panel2SessionId1 = await getSearchSessionIdByPanel(
const panel1SessionId1 = await dashboardPanelActions.getSearchSessionIdByTitle(
'Sum of Bytes by Extension'
);
const panel2SessionId1 = await dashboardPanelActions.getSearchSessionIdByTitle(
'Sum of Bytes by Extension (Delayed 5s)'
);
expect(panel1SessionId1).to.be(panel2SessionId1);
await queryBar.clickQuerySubmitButton();
const panel1SessionId2 = await getSearchSessionIdByPanel('Sum of Bytes by Extension');
const panel2SessionId2 = await getSearchSessionIdByPanel(
const panel1SessionId2 = await dashboardPanelActions.getSearchSessionIdByTitle(
'Sum of Bytes by Extension'
);
const panel2SessionId2 = await dashboardPanelActions.getSearchSessionIdByTitle(
'Sum of Bytes by Extension (Delayed 5s)'
);
expect(panel1SessionId2).to.be(panel2SessionId2);
expect(panel1SessionId1).not.to.be(panel1SessionId2);
});
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);
// switching dashboard to edit mode (or any other non-fetch required) state change
// should leave session state untouched
await PageObjects.dashboard.switchToEditMode();
await sendToBackground.expectState('restored');
});
});
});
}

View file

@ -1,22 +0,0 @@
/*
* 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.
*/
// HELPERS
export function getSearchSessionIdByPanelProvider(getService: any) {
const dashboardPanelActions = getService('dashboardPanelActions');
const inspector = getService('inspector');
const testSubjects = getService('testSubjects');
return async function getSearchSessionIdByPanel(panelTitle: string) {
await dashboardPanelActions.openInspectorByTitle(panelTitle);
await inspector.openInspectorRequestsView();
const searchSessionId = await (
await testSubjects.find('inspectorRequestSearchSessionId')
).getAttribute('data-search-session-id');
await inspector.close();
return searchSessionId;
};
}

View file

@ -24,6 +24,8 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) {
});
loadTestFile(require.resolve('./async_search'));
loadTestFile(require.resolve('./send_to_background'));
loadTestFile(require.resolve('./send_to_background_relative_time'));
loadTestFile(require.resolve('./sessions_in_space'));
});
}

View file

@ -0,0 +1,83 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const es = getService('es');
const testSubjects = getService('testSubjects');
const log = getService('log');
const PageObjects = getPageObjects(['common', 'header', 'dashboard', 'visChart']);
const dashboardPanelActions = getService('dashboardPanelActions');
const browser = getService('browser');
const sendToBackground = getService('sendToBackground');
describe('send to background', () => {
before(async function () {
const { body } = await es.info();
if (!body.version.number.includes('SNAPSHOT')) {
log.debug('Skipping because this build does not have the required shard_delay agg');
this.skip();
}
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 dashboardPanelActions.getSearchSessionIdByTitle(
'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 dashboardPanelActions.getSearchSessionIdByTitle(
'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 dashboardPanelActions.getSearchSessionIdByTitle(
'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);
// switching dashboard to edit mode (or any other non-fetch required) state change
// should leave session state untouched
await PageObjects.dashboard.switchToEditMode();
await sendToBackground.expectState('restored');
});
});
}

View file

@ -0,0 +1,119 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const log = getService('log');
const retry = getService('retry');
const PageObjects = getPageObjects([
'common',
'header',
'dashboard',
'visChart',
'home',
'timePicker',
]);
const dashboardPanelActions = getService('dashboardPanelActions');
const inspector = getService('inspector');
const pieChart = getService('pieChart');
const find = getService('find');
const dashboardExpect = getService('dashboardExpect');
const queryBar = getService('queryBar');
const browser = getService('browser');
const sendToBackground = getService('sendToBackground');
describe('send to background with relative time', () => {
before(async () => {
await PageObjects.common.sleep(5000); // this part was copied from `x-pack/test/functional/apps/dashboard/_async_dashboard.ts` and this was sleep was needed because of flakiness
await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', {
useActualUrl: true,
});
await PageObjects.header.waitUntilLoadingHasFinished();
// use sample data set because it has recent relative time range and bunch of different visualizations
await PageObjects.home.addSampleDataSet('flights');
await retry.tryForTime(10000, async () => {
const isInstalled = await PageObjects.home.isSampleDataSetInstalled('flights');
expect(isInstalled).to.be(true);
});
await PageObjects.common.navigateToApp('dashboard');
});
after(async () => {
await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', {
useActualUrl: true,
});
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.home.removeSampleDataSet('flights');
const isInstalled = await PageObjects.home.isSampleDataSetInstalled('flights');
expect(isInstalled).to.be(false);
});
it('Saves and restores a session with relative time ranges', async () => {
await PageObjects.dashboard.loadSavedDashboard('[Flights] Global Flight Dashboard');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.timePicker.pauseAutoRefresh(); // sample data has auto-refresh on
await queryBar.submitQuery();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
await checkSampleDashboardLoaded();
await sendToBackground.expectState('completed');
await sendToBackground.save();
await sendToBackground.expectState('backgroundCompleted');
const savedSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(
'[Flights] Airline Carrier'
);
const resolvedTimeRange = await getResolvedTimeRangeFromPanel('[Flights] Airline Carrier');
// load URL to restore a saved session
const url = await browser.getCurrentUrl();
const savedSessionURL = `${url}&searchSessionId=${savedSessionId}`
.replace('now-24h', `'${resolvedTimeRange.gte}'`)
.replace('now', `'${resolvedTimeRange.lte}'`);
log.debug('Trying to restore session by URL:', savedSessionId);
await browser.get(savedSessionURL);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
await checkSampleDashboardLoaded();
// Check that session is restored
await sendToBackground.expectState('restored');
});
});
// HELPERS
async function getResolvedTimeRangeFromPanel(
panelTitle: string
): Promise<{ gte: string; lte: string }> {
await dashboardPanelActions.openInspectorByTitle(panelTitle);
await inspector.openInspectorRequestsView();
await (await inspector.getOpenRequestDetailRequestButton()).click();
const request = JSON.parse(await inspector.getCodeEditorValue());
return request.query.bool.filter.find((f: any) => f.range).range.timestamp;
}
async function checkSampleDashboardLoaded() {
log.debug('Checking no error labels');
await testSubjects.missingOrFail('embeddableErrorLabel');
log.debug('Checking pie charts rendered');
await pieChart.expectPieSliceCount(4);
log.debug('Checking area, bar and heatmap charts rendered');
await dashboardExpect.seriesElementCount(15);
log.debug('Checking saved searches rendered');
await dashboardExpect.savedSearchRowCount(50);
log.debug('Checking input controls rendered');
await dashboardExpect.inputControlItemCount(3);
log.debug('Checking tag cloud rendered');
await dashboardExpect.tagCloudWithValuesFound(['Sunny', 'Rain', 'Clear', 'Cloudy', 'Hail']);
log.debug('Checking vega chart rendered');
const tsvb = await find.existsByCssSelector('.vgaVis__view');
expect(tsvb).to.be(true);
}
}

View file

@ -5,7 +5,6 @@
*/
import { FtrProviderContext } from '../../../../ftr_provider_context';
import { getSearchSessionIdByPanelProvider } from './get_search_session_id_by_panel';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
@ -19,7 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'security',
'timePicker',
]);
const getSearchSessionIdByPanel = getSearchSessionIdByPanelProvider(getService);
const dashboardPanelActions = getService('dashboardPanelActions');
const browser = getService('browser');
const sendToBackground = getService('sendToBackground');
@ -77,7 +76,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await sendToBackground.expectState('completed');
await sendToBackground.save();
await sendToBackground.expectState('backgroundCompleted');
const savedSessionId = await getSearchSessionIdByPanel('A Pie in another space');
const savedSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(
'A Pie in another space'
);
// load URL to restore a saved session
const url = await browser.getCurrentUrl();