Forráskód Böngészése

Explore: Use PanelQueryState to handle querying (#18694)

* WIP: inital POC

* Wip: Moving forward

* Wip

* Refactor: Makes loading indicator work for Prometheus

* Refactor: Reverts prom observable queries because they did not work for multiple targets

* Refactor: Transforms all epics into thunks

* Fix: Fixes scanning

* Fix: Fixes so that Instant and TimeSeries Prom query loads in parallel

* Fix: Fixes negation logic error

* Propagate errors in stream events, and close streams
Hugo Häggmark 6 éve
szülő
commit
5ca643f2ba
28 módosított fájl, 486 hozzáadás és 1751 törlés
  1. 0 1
      package.json
  2. 21 14
      public/app/core/utils/explore.ts
  3. 15 0
      public/app/features/dashboard/state/PanelQueryState.test.ts
  4. 7 3
      public/app/features/dashboard/state/PanelQueryState.ts
  5. 1 0
      public/app/features/explore/QueryField.tsx
  6. 9 66
      public/app/features/explore/state/actionTypes.ts
  7. 8 16
      public/app/features/explore/state/actions.test.ts
  8. 342 20
      public/app/features/explore/state/actions.ts
  9. 0 25
      public/app/features/explore/state/epics/limitMessageRateEpic.ts
  10. 0 67
      public/app/features/explore/state/epics/processQueryErrorsEpic.test.ts
  11. 0 40
      public/app/features/explore/state/epics/processQueryErrorsEpic.ts
  12. 0 119
      public/app/features/explore/state/epics/processQueryResultsEpic.test.ts
  13. 0 82
      public/app/features/explore/state/epics/processQueryResultsEpic.ts
  14. 0 425
      public/app/features/explore/state/epics/runQueriesBatchEpic.test.ts
  15. 0 231
      public/app/features/explore/state/epics/runQueriesBatchEpic.ts
  16. 0 71
      public/app/features/explore/state/epics/runQueriesEpic.test.ts
  17. 0 39
      public/app/features/explore/state/epics/runQueriesEpic.ts
  18. 0 62
      public/app/features/explore/state/epics/stateSaveEpic.test.ts
  19. 0 75
      public/app/features/explore/state/epics/stateSaveEpic.ts
  20. 0 105
      public/app/features/explore/state/epics/timeEpic.test.ts
  21. 0 47
      public/app/features/explore/state/epics/timeEpic.ts
  22. 5 0
      public/app/features/explore/state/reducers.test.ts
  23. 34 3
      public/app/features/explore/state/reducers.ts
  24. 3 7
      public/app/plugins/datasource/loki/datasource.ts
  25. 36 13
      public/app/plugins/datasource/prometheus/datasource.ts
  26. 2 71
      public/app/store/configureStore.ts
  27. 3 0
      public/app/types/explore.ts
  28. 0 149
      public/test/core/redux/epicTester.ts

+ 0 - 1
package.json

@@ -230,7 +230,6 @@
     "react-window": "1.7.1",
     "redux": "4.0.1",
     "redux-logger": "3.0.6",
-    "redux-observable": "1.1.0",
     "redux-thunk": "2.3.0",
     "reselect": "4.0.0",
     "rst2html": "github:thoward/rst2html#990cb89",

+ 21 - 14
public/app/core/utils/explore.ts

@@ -2,14 +2,23 @@
 import _ from 'lodash';
 import { from } from 'rxjs';
 import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker';
-
 // Services & Utils
-import { dateMath } from '@grafana/data';
+import {
+  dateMath,
+  toUtc,
+  TimeRange,
+  RawTimeRange,
+  TimeZone,
+  IntervalValues,
+  TimeFragment,
+  LogRowModel,
+  LogsModel,
+  LogsDedupStrategy,
+} from '@grafana/data';
 import { renderUrl } from 'app/core/utils/url';
 import kbn from 'app/core/utils/kbn';
 import store from 'app/core/store';
 import { getNextRefIdChar } from './query';
-
 // Types
 import {
   DataQuery,
@@ -19,17 +28,6 @@ import {
   DataQueryRequest,
   DataStreamObserver,
 } from '@grafana/ui';
-import {
-  toUtc,
-  TimeRange,
-  RawTimeRange,
-  TimeZone,
-  IntervalValues,
-  TimeFragment,
-  LogRowModel,
-  LogsModel,
-  LogsDedupStrategy,
-} from '@grafana/data';
 import {
   ExploreUrlState,
   HistoryItem,
@@ -39,6 +37,7 @@ import {
   ExploreMode,
 } from 'app/types/explore';
 import { config } from '../config';
+import { PanelQueryState } from '../../features/dashboard/state/PanelQueryState';
 
 export const DEFAULT_RANGE = {
   from: 'now-1h',
@@ -145,6 +144,7 @@ export function buildQueryTransaction(
     panelId,
     targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
     range,
+    requestId: 'explore',
     rangeRaw: range.raw,
     scopedVars: {
       __interval: { text: interval, value: interval },
@@ -542,3 +542,10 @@ export const getQueryResponse = (
 ) => {
   return from(datasourceInstance.query(options, observer));
 };
+
+export const stopQueryState = (queryState: PanelQueryState, reason: string) => {
+  if (queryState && queryState.isStarted()) {
+    queryState.cancel(reason);
+    queryState.closeStreams(false);
+  }
+};

+ 15 - 0
public/app/features/dashboard/state/PanelQueryState.test.ts

@@ -207,4 +207,19 @@ describe('stream handling', () => {
     expect(data.series[0].refId).toBe('F');
     expect(state.streams.length).toBe(0); // no streams
   });
+
+  it('should close streams on error', () => {
+    // Post a stream event
+    state.dataStreamObserver({
+      state: LoadingState.Error,
+      key: 'C',
+      error: { message: 'EEEEE' },
+      data: [],
+      request: state.request,
+      unsubscribe: () => {},
+    });
+
+    expect(state.streams.length).toBe(0);
+    expect(state.response.state).toBe(LoadingState.Error);
+  });
 });

+ 7 - 3
public/app/features/dashboard/state/PanelQueryState.ts

@@ -1,10 +1,9 @@
 // Libraries
 import { isArray, isEqual, isString } from 'lodash';
-
 // Utils & Services
 import { getBackendSrv } from 'app/core/services/backend_srv';
-import { dateMath } from '@grafana/data';
 import {
+  dateMath,
   guessFieldTypes,
   LoadingState,
   toLegacyResponseData,
@@ -12,7 +11,6 @@ import {
   toDataFrame,
   isDataFrame,
 } from '@grafana/data';
-
 // Types
 import {
   DataSourceApi,
@@ -161,6 +159,12 @@ export class PanelQueryState {
     // Streams only work with the 'series' format
     this.sendFrames = true;
 
+    if (stream.state === LoadingState.Error) {
+      this.setError(stream.error);
+      this.onStreamingDataUpdated();
+      return;
+    }
+
     // Add the stream to our list
     let found = false;
     const active = this.streams.map(s => {

+ 1 - 0
public/app/features/explore/QueryField.tsx

@@ -8,6 +8,7 @@ import { Editor } from 'slate-react';
 // @ts-ignore
 import Plain from 'slate-plain-serializer';
 import classnames from 'classnames';
+// @ts-ignore
 import { isKeyHotkey } from 'is-hotkey';
 
 import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';

+ 9 - 66
public/app/features/explore/state/actionTypes.ts

@@ -1,25 +1,9 @@
 // Types
 import { Emitter } from 'app/core/core';
-import {
-  DataQuery,
-  DataSourceSelectItem,
-  DataSourceApi,
-  QueryFixAction,
-  DataQueryError,
-  DataQueryResponseData,
-} from '@grafana/ui';
-
-import {
-  RawTimeRange,
-  LogLevel,
-  TimeRange,
-  DataFrame,
-  LogsModel,
-  LoadingState,
-  AbsoluteTimeRange,
-  GraphSeriesXY,
-} from '@grafana/data';
-import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode, QueryOptions } from 'app/types/explore';
+import { DataQuery, DataSourceSelectItem, DataSourceApi, QueryFixAction, DataQueryError } from '@grafana/ui';
+
+import { LogLevel, TimeRange, LogsModel, LoadingState, AbsoluteTimeRange, GraphSeriesXY } from '@grafana/data';
+import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode } from 'app/types/explore';
 import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
 import TableModel from 'app/core/table_model';
 
@@ -230,42 +214,15 @@ export interface SetUrlReplacedPayload {
   exploreId: ExploreId;
 }
 
-export interface ProcessQueryErrorsPayload {
-  exploreId: ExploreId;
-  response: any;
-  datasourceId: string;
-}
-
-export interface ProcessQueryResultsPayload {
-  exploreId: ExploreId;
-  latency: number;
-  datasourceId: string;
-  loadingState: LoadingState;
-  series?: DataQueryResponseData[];
-  delta?: DataFrame[];
-}
-
-export interface RunQueriesBatchPayload {
-  exploreId: ExploreId;
-  queryOptions: QueryOptions;
-}
-
-export interface LimitMessageRatePayload {
-  series: DataFrame[];
-  exploreId: ExploreId;
-  datasourceId: string;
-}
-
 export interface ChangeRangePayload {
   exploreId: ExploreId;
   range: TimeRange;
   absoluteRange: AbsoluteTimeRange;
 }
 
-export interface UpdateTimeRangePayload {
+export interface ChangeLoadingStatePayload {
   exploreId: ExploreId;
-  rawRange?: RawTimeRange;
-  absoluteRange?: AbsoluteTimeRange;
+  loadingState: LoadingState;
 }
 
 /**
@@ -410,8 +367,6 @@ export const splitCloseAction = actionCreatorFactory<SplitCloseActionPayload>('e
  */
 export const splitOpenAction = actionCreatorFactory<SplitOpenPayload>('explore/SPLIT_OPEN').create();
 
-export const stateSaveAction = noPayloadActionCreatorFactory('explore/STATE_SAVE').create();
-
 /**
  * Update state of Explores UI elements (panels visiblity and deduplication  strategy)
  */
@@ -460,23 +415,11 @@ export const resetQueryErrorAction = actionCreatorFactory<ResetQueryErrorPayload
 
 export const setUrlReplacedAction = actionCreatorFactory<SetUrlReplacedPayload>('explore/SET_URL_REPLACED').create();
 
-export const processQueryErrorsAction = actionCreatorFactory<ProcessQueryErrorsPayload>(
-  'explore/PROCESS_QUERY_ERRORS'
-).create();
-
-export const processQueryResultsAction = actionCreatorFactory<ProcessQueryResultsPayload>(
-  'explore/PROCESS_QUERY_RESULTS'
-).create();
-
-export const runQueriesBatchAction = actionCreatorFactory<RunQueriesBatchPayload>('explore/RUN_QUERIES_BATCH').create();
-
-export const limitMessageRatePayloadAction = actionCreatorFactory<LimitMessageRatePayload>(
-  'explore/LIMIT_MESSAGE_RATE_PAYLOAD'
-).create();
-
 export const changeRangeAction = actionCreatorFactory<ChangeRangePayload>('explore/CHANGE_RANGE').create();
 
-export const updateTimeRangeAction = actionCreatorFactory<UpdateTimeRangePayload>('explore/UPDATE_TIMERANGE').create();
+export const changeLoadingStateAction = actionCreatorFactory<ChangeLoadingStatePayload>(
+  'changeLoadingStateAction'
+).create();
 
 export type HigherOrderAction =
   | ActionOf<SplitCloseActionPayload>

+ 8 - 16
public/app/features/explore/state/actions.test.ts

@@ -11,14 +11,12 @@ import {
   testDataSourceFailureAction,
   loadDatasourcePendingAction,
   loadDatasourceReadyAction,
-  updateTimeRangeAction,
 } from './actionTypes';
 import { Emitter } from 'app/core/core';
 import { ActionOf } from 'app/core/redux/actionCreatorFactory';
 import { makeInitialUpdateState } from './reducers';
 import { DataQuery } from '@grafana/ui/src/types/datasource';
-import { DefaultTimeZone, RawTimeRange, LogsDedupStrategy } from '@grafana/data';
-import { toUtc } from '@grafana/data';
+import { DefaultTimeZone, RawTimeRange, LogsDedupStrategy, toUtc } from '@grafana/data';
 
 jest.mock('app/features/plugins/datasource_srv', () => ({
   getDatasourceSrv: () => ({
@@ -30,6 +28,12 @@ jest.mock('app/features/plugins/datasource_srv', () => ({
   }),
 }));
 
+jest.mock('../../dashboard/services/TimeSrv', () => ({
+  getTimeSrv: jest.fn().mockReturnValue({
+    init: jest.fn(),
+  }),
+}));
+
 const t = toUtc();
 const testRange = {
   from: t,
@@ -62,6 +66,7 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
   const update = { ...updateDefaults, ...updateOverides };
   const initialState = {
     user: {
+      orgId: '1',
       timeZone,
     },
     explore: {
@@ -118,19 +123,6 @@ describe('refreshExplore', () => {
       });
     });
 
-    describe('and update range is set', () => {
-      it('then it should dispatch updateTimeRangeAction', async () => {
-        const { exploreId, range, initialState } = setup({ range: true });
-
-        const dispatchedActions = await thunkTester(initialState)
-          .givenThunk(refreshExplore)
-          .whenThunkIsDispatched(exploreId);
-
-        expect(dispatchedActions[0].type).toEqual(updateTimeRangeAction.type);
-        expect(dispatchedActions[0].payload).toEqual({ exploreId, rawRange: range.raw });
-      });
-    });
-
     describe('and update ui is set', () => {
       it('then it should dispatch updateUIStateAction', async () => {
         const { exploreId, initialState, ui } = setup({ ui: true });

+ 342 - 20
public/app/features/explore/state/actions.ts

@@ -1,6 +1,4 @@
 // Libraries
-import _ from 'lodash';
-
 // Services & Utils
 import store from 'app/core/store';
 import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
@@ -13,13 +11,36 @@ import {
   getTimeRangeFromUrl,
   generateNewKeyAndAddRefIdIfMissing,
   lastUsedDatasourceKeyForOrgId,
+  hasNonEmptyQuery,
+  buildQueryTransaction,
+  updateHistory,
+  getRefIds,
+  instanceOfDataQueryError,
+  clearQueryKeys,
+  serializeStateToUrlParam,
+  stopQueryState,
 } from 'app/core/utils/explore';
-
 // Types
-import { ThunkResult } from 'app/types';
-import { DataSourceApi, DataQuery, DataSourceSelectItem, QueryFixAction } from '@grafana/ui';
+import { ThunkResult, ExploreUrlState } from 'app/types';
+import {
+  DataSourceApi,
+  DataQuery,
+  DataSourceSelectItem,
+  QueryFixAction,
+  PanelData,
+  DataQueryResponseData,
+} from '@grafana/ui';
 
-import { RawTimeRange, LogsDedupStrategy, AbsoluteTimeRange } from '@grafana/data';
+import {
+  RawTimeRange,
+  LogsDedupStrategy,
+  AbsoluteTimeRange,
+  LoadingState,
+  DataFrame,
+  TimeRange,
+  isDateTime,
+  dateTimeForTimeZone,
+} from '@grafana/data';
 import { ExploreId, ExploreUIState, QueryTransaction, ExploreMode } from 'app/types/explore';
 import {
   updateDatasourceInstanceAction,
@@ -52,14 +73,24 @@ import {
   loadExploreDatasources,
   changeModeAction,
   scanStopAction,
-  runQueriesAction,
-  stateSaveAction,
-  updateTimeRangeAction,
+  changeLoadingStateAction,
+  historyUpdatedAction,
+  queryStartAction,
+  resetQueryErrorAction,
+  querySuccessAction,
+  queryFailureAction,
+  setUrlReplacedAction,
+  changeRangeAction,
 } from './actionTypes';
 import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
 import { getTimeZone } from 'app/features/profile/state/selectors';
 import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker';
 import { getShiftedTimeRange } from 'app/core/utils/timePicker';
+import { ResultProcessor } from '../utils/ResultProcessor';
+import _ from 'lodash';
+import { toDataQueryError } from '../../dashboard/state/PanelQueryState';
+import { updateLocation } from '../../../core/actions';
+import { getTimeSrv } from '../../dashboard/services/TimeSrv';
 
 /**
  * Updates UI state and save it to the URL
@@ -67,7 +98,7 @@ import { getShiftedTimeRange } from 'app/core/utils/timePicker';
 const updateExploreUIState = (exploreId: ExploreId, uiStateFragment: Partial<ExploreUIState>): ThunkResult<void> => {
   return dispatch => {
     dispatch(updateUIStateAction({ exploreId, ...uiStateFragment }));
-    dispatch(stateSaveAction());
+    dispatch(stateSave());
   };
 };
 
@@ -165,7 +196,7 @@ export const updateTimeRange = (options: {
   absoluteRange?: AbsoluteTimeRange;
 }): ThunkResult<void> => {
   return dispatch => {
-    dispatch(updateTimeRangeAction({ ...options }));
+    dispatch(updateTime({ ...options }));
     dispatch(runQueries(options.exploreId));
   };
 };
@@ -187,7 +218,7 @@ export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
   return dispatch => {
     dispatch(scanStopAction({ exploreId }));
     dispatch(clearQueriesAction({ exploreId }));
-    dispatch(stateSaveAction());
+    dispatch(stateSave());
   };
 }
 
@@ -250,7 +281,7 @@ export function initializeExplore(
         ui,
       })
     );
-    dispatch(updateTimeRangeAction({ exploreId }));
+    dispatch(updateTime({ exploreId }));
   };
 }
 
@@ -401,11 +432,302 @@ export function modifyQueries(
  */
 export function runQueries(exploreId: ExploreId): ThunkResult<void> {
   return (dispatch, getState) => {
-    dispatch(updateTimeRangeAction({ exploreId }));
-    dispatch(runQueriesAction({ exploreId }));
+    dispatch(updateTime({ exploreId }));
+
+    const exploreItemState = getState().explore[exploreId];
+    const {
+      datasourceInstance,
+      queries,
+      datasourceError,
+      containerWidth,
+      isLive: live,
+      queryState,
+      queryIntervals,
+      range,
+      scanning,
+      history,
+    } = exploreItemState;
+
+    if (datasourceError) {
+      // let's not run any queries if data source is in a faulty state
+      return;
+    }
+
+    if (!hasNonEmptyQuery(queries)) {
+      dispatch(clearQueriesAction({ exploreId }));
+      dispatch(stateSave()); // Remember to save to state and update location
+      return;
+    }
+
+    // Some datasource's query builders allow per-query interval limits,
+    // but we're using the datasource interval limit for now
+    const interval = datasourceInstance.interval;
+
+    stopQueryState(queryState, 'New request issued');
+
+    queryState.sendFrames = true;
+    queryState.sendLegacy = true; // temporary hack until we switch to PanelData
+
+    const queryOptions = { interval, maxDataPoints: containerWidth, live };
+    const datasourceId = datasourceInstance.meta.id;
+    const now = Date.now();
+    const transaction = buildQueryTransaction(queries, queryOptions, range, queryIntervals, scanning);
+
+    // temporary hack until we switch to PanelData, Loki already converts to DataFrame so using legacy will destroy the format
+    const isLokiDataSource = datasourceInstance.meta.name === 'Loki';
+
+    queryState.onStreamingDataUpdated = () => {
+      const data = queryState.validateStreamsAndGetPanelData();
+      const { state, error, legacy, series } = data;
+      if (!data && !error && !legacy && !series) {
+        return;
+      }
+
+      if (state === LoadingState.Error) {
+        dispatch(processErrorResults({ exploreId, response: error, datasourceId }));
+        return;
+      }
+
+      if (state === LoadingState.Streaming) {
+        dispatch(limitMessageRate(exploreId, isLokiDataSource ? series : legacy, datasourceId));
+        return;
+      }
+
+      if (state === LoadingState.Done) {
+        dispatch(changeLoadingStateAction({ exploreId, loadingState: state }));
+      }
+    };
+
+    dispatch(queryStartAction({ exploreId }));
+
+    queryState
+      .execute(datasourceInstance, transaction.options)
+      .then((response: PanelData) => {
+        const { legacy, error, series } = response;
+        if (error) {
+          dispatch(processErrorResults({ exploreId, response: error, datasourceId }));
+          return;
+        }
+
+        const latency = Date.now() - now;
+        // Side-effect: Saving history in localstorage
+        const nextHistory = updateHistory(history, datasourceId, queries);
+        dispatch(historyUpdatedAction({ exploreId, history: nextHistory }));
+        dispatch(
+          processQueryResults({
+            exploreId,
+            latency,
+            datasourceId,
+            loadingState: LoadingState.Done,
+            series: isLokiDataSource ? series : legacy,
+          })
+        );
+        dispatch(stateSave());
+      })
+      .catch(error => {
+        dispatch(processErrorResults({ exploreId, response: error, datasourceId }));
+      });
   };
 }
 
+export const limitMessageRate = (
+  exploreId: ExploreId,
+  series: DataFrame[] | any[],
+  datasourceId: string
+): ThunkResult<void> => {
+  return (dispatch, getState) => {
+    dispatch(
+      processQueryResults({
+        exploreId,
+        latency: 0,
+        datasourceId,
+        loadingState: LoadingState.Streaming,
+        series,
+      })
+    );
+  };
+};
+
+export const processQueryResults = (config: {
+  exploreId: ExploreId;
+  latency: number;
+  datasourceId: string;
+  loadingState: LoadingState;
+  series?: DataQueryResponseData[];
+}): ThunkResult<void> => {
+  return (dispatch, getState) => {
+    const { exploreId, datasourceId, latency, loadingState, series } = config;
+    const { datasourceInstance, scanning, eventBridge } = getState().explore[exploreId];
+
+    // If datasource already changed, results do not matter
+    if (datasourceInstance.meta.id !== datasourceId) {
+      return;
+    }
+
+    const result = series || [];
+    const replacePreviousResults = loadingState === LoadingState.Done && series ? true : false;
+    const resultProcessor = new ResultProcessor(getState().explore[exploreId], replacePreviousResults, result);
+    const graphResult = resultProcessor.getGraphResult();
+    const tableResult = resultProcessor.getTableResult();
+    const logsResult = resultProcessor.getLogsResult();
+    const refIds = getRefIds(result);
+
+    // For Angular editors
+    eventBridge.emit('data-received', resultProcessor.getRawData());
+
+    // Clears any previous errors that now have a successful query, important so Angular editors are updated correctly
+    dispatch(resetQueryErrorAction({ exploreId, refIds }));
+
+    dispatch(
+      querySuccessAction({
+        exploreId,
+        latency,
+        loadingState,
+        graphResult,
+        tableResult,
+        logsResult,
+      })
+    );
+
+    // Keep scanning for results if this was the last scanning transaction
+    if (scanning) {
+      if (_.size(result) === 0) {
+        const range = getShiftedTimeRange(-1, getState().explore[exploreId].range);
+        dispatch(updateTime({ exploreId, absoluteRange: range }));
+        dispatch(runQueries(exploreId));
+      } else {
+        // We can stop scanning if we have a result
+        dispatch(scanStopAction({ exploreId }));
+      }
+    }
+  };
+};
+
+export const processErrorResults = (config: {
+  exploreId: ExploreId;
+  response: any;
+  datasourceId: string;
+}): ThunkResult<void> => {
+  return (dispatch, getState) => {
+    const { exploreId, datasourceId } = config;
+    let { response } = config;
+    const { datasourceInstance, eventBridge } = getState().explore[exploreId];
+
+    if (datasourceInstance.meta.id !== datasourceId || response.cancelled) {
+      // Navigated away, queries did not matter
+      return;
+    }
+
+    // For Angular editors
+    eventBridge.emit('data-error', response);
+
+    console.error(response); // To help finding problems with query syntax
+
+    if (!instanceOfDataQueryError(response)) {
+      response = toDataQueryError(response);
+    }
+
+    dispatch(queryFailureAction({ exploreId, response }));
+  };
+};
+
+const toRawTimeRange = (range: TimeRange): RawTimeRange => {
+  let from = range.raw.from;
+  if (isDateTime(from)) {
+    from = from.valueOf().toString(10);
+  }
+
+  let to = range.raw.to;
+  if (isDateTime(to)) {
+    to = to.valueOf().toString(10);
+  }
+
+  return {
+    from,
+    to,
+  };
+};
+
+export const stateSave = (): ThunkResult<void> => {
+  return (dispatch, getState) => {
+    const { left, right, split } = getState().explore;
+    const orgId = getState().user.orgId.toString();
+    const replace = left && left.urlReplaced === false;
+    const urlStates: { [index: string]: string } = { orgId };
+    const leftUrlState: ExploreUrlState = {
+      datasource: left.datasourceInstance.name,
+      queries: left.queries.map(clearQueryKeys),
+      range: toRawTimeRange(left.range),
+      mode: left.mode,
+      ui: {
+        showingGraph: left.showingGraph,
+        showingLogs: true,
+        showingTable: left.showingTable,
+        dedupStrategy: left.dedupStrategy,
+      },
+    };
+    urlStates.left = serializeStateToUrlParam(leftUrlState, true);
+    if (split) {
+      const rightUrlState: ExploreUrlState = {
+        datasource: right.datasourceInstance.name,
+        queries: right.queries.map(clearQueryKeys),
+        range: toRawTimeRange(right.range),
+        mode: right.mode,
+        ui: {
+          showingGraph: right.showingGraph,
+          showingLogs: true,
+          showingTable: right.showingTable,
+          dedupStrategy: right.dedupStrategy,
+        },
+      };
+
+      urlStates.right = serializeStateToUrlParam(rightUrlState, true);
+    }
+
+    dispatch(updateLocation({ query: urlStates, replace }));
+    if (replace) {
+      dispatch(setUrlReplacedAction({ exploreId: ExploreId.left }));
+    }
+  };
+};
+
+export const updateTime = (config: {
+  exploreId: ExploreId;
+  rawRange?: RawTimeRange;
+  absoluteRange?: AbsoluteTimeRange;
+}): ThunkResult<void> => {
+  return (dispatch, getState) => {
+    const { exploreId, absoluteRange: absRange, rawRange: actionRange } = config;
+    const itemState = getState().explore[exploreId];
+    const timeZone = getTimeZone(getState().user);
+    const { range: rangeInState } = itemState;
+    let rawRange: RawTimeRange = rangeInState.raw;
+
+    if (absRange) {
+      rawRange = {
+        from: dateTimeForTimeZone(timeZone, absRange.from),
+        to: dateTimeForTimeZone(timeZone, absRange.to),
+      };
+    }
+
+    if (actionRange) {
+      rawRange = actionRange;
+    }
+
+    const range = getTimeRange(timeZone, rawRange);
+    const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
+
+    getTimeSrv().init({
+      time: range.raw,
+      refresh: false,
+      getTimezone: () => timeZone,
+      timeRangeUpdated: (): any => undefined,
+    });
+
+    dispatch(changeRangeAction({ exploreId, range, absoluteRange }));
+  };
+};
+
 /**
  * Start a scan for more results using the given scanner.
  * @param exploreId Explore area
@@ -418,8 +740,8 @@ export function scanStart(exploreId: ExploreId): ThunkResult<void> {
     // Scanning must trigger query run, and return the new range
     const range = getShiftedTimeRange(-1, getState().explore[exploreId].range);
     // Set the new range to be displayed
-    dispatch(updateTimeRangeAction({ exploreId, absoluteRange: range }));
-    dispatch(runQueriesAction({ exploreId }));
+    dispatch(updateTime({ exploreId, absoluteRange: range }));
+    dispatch(runQueries(exploreId));
   };
 }
 
@@ -443,7 +765,7 @@ export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): Thunk
 export function splitClose(itemId: ExploreId): ThunkResult<void> {
   return dispatch => {
     dispatch(splitCloseAction({ itemId }));
-    dispatch(stateSaveAction());
+    dispatch(stateSave());
   };
 }
 
@@ -467,7 +789,7 @@ export function splitOpen(): ThunkResult<void> {
       urlState,
     };
     dispatch(splitOpenAction({ itemState }));
-    dispatch(stateSaveAction());
+    dispatch(stateSave());
   };
 }
 
@@ -544,7 +866,7 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
     }
 
     if (update.range) {
-      dispatch(updateTimeRangeAction({ exploreId, rawRange: range.raw }));
+      dispatch(updateTime({ exploreId, rawRange: range.raw }));
     }
 
     // need to refresh ui state

+ 0 - 25
public/app/features/explore/state/epics/limitMessageRateEpic.ts

@@ -1,25 +0,0 @@
-import { Epic } from 'redux-observable';
-import { map, throttleTime } from 'rxjs/operators';
-import { LoadingState } from '@grafana/data';
-
-import { StoreState } from 'app/types';
-import { ActionOf } from '../../../../core/redux/actionCreatorFactory';
-import { limitMessageRatePayloadAction, LimitMessageRatePayload, processQueryResultsAction } from '../actionTypes';
-import { EpicDependencies } from 'app/store/configureStore';
-
-export const limitMessageRateEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState, EpicDependencies> = action$ => {
-  return action$.ofType(limitMessageRatePayloadAction.type).pipe(
-    throttleTime(1),
-    map((action: ActionOf<LimitMessageRatePayload>) => {
-      const { exploreId, series, datasourceId } = action.payload;
-      return processQueryResultsAction({
-        exploreId,
-        latency: 0,
-        datasourceId,
-        loadingState: LoadingState.Streaming,
-        series: null,
-        delta: series,
-      });
-    })
-  );
-};

+ 0 - 67
public/app/features/explore/state/epics/processQueryErrorsEpic.test.ts

@@ -1,67 +0,0 @@
-import { mockExploreState } from 'test/mocks/mockExploreState';
-import { epicTester } from 'test/core/redux/epicTester';
-import { processQueryErrorsAction, queryFailureAction } from '../actionTypes';
-import { processQueryErrorsEpic } from './processQueryErrorsEpic';
-
-describe('processQueryErrorsEpic', () => {
-  let originalConsoleError = console.error;
-
-  beforeEach(() => {
-    originalConsoleError = console.error;
-    console.error = jest.fn();
-  });
-
-  afterEach(() => {
-    console.error = originalConsoleError;
-  });
-
-  describe('when processQueryErrorsAction is dispatched', () => {
-    describe('and datasourceInstance is the same', () => {
-      describe('and the response is not cancelled', () => {
-        it('then queryFailureAction is dispatched', () => {
-          const { datasourceId, exploreId, state, eventBridge } = mockExploreState();
-          const response = { message: 'Something went terribly wrong!' };
-
-          epicTester(processQueryErrorsEpic, state)
-            .whenActionIsDispatched(processQueryErrorsAction({ exploreId, datasourceId, response }))
-            .thenResultingActionsEqual(queryFailureAction({ exploreId, response }));
-
-          expect(console.error).toBeCalledTimes(1);
-          expect(console.error).toBeCalledWith(response);
-          expect(eventBridge.emit).toBeCalledTimes(1);
-          expect(eventBridge.emit).toBeCalledWith('data-error', response);
-        });
-      });
-
-      describe('and the response is cancelled', () => {
-        it('then no actions are dispatched', () => {
-          const { datasourceId, exploreId, state, eventBridge } = mockExploreState();
-          const response = { cancelled: true, message: 'Something went terribly wrong!' };
-
-          epicTester(processQueryErrorsEpic, state)
-            .whenActionIsDispatched(processQueryErrorsAction({ exploreId, datasourceId, response }))
-            .thenNoActionsWhereDispatched();
-
-          expect(console.error).not.toBeCalled();
-          expect(eventBridge.emit).not.toBeCalled();
-        });
-      });
-    });
-
-    describe('and datasourceInstance is not the same', () => {
-      describe('and the response is not cancelled', () => {
-        it('then no actions are dispatched', () => {
-          const { exploreId, state, eventBridge } = mockExploreState();
-          const response = { message: 'Something went terribly wrong!' };
-
-          epicTester(processQueryErrorsEpic, state)
-            .whenActionIsDispatched(processQueryErrorsAction({ exploreId, datasourceId: 'other id', response }))
-            .thenNoActionsWhereDispatched();
-
-          expect(console.error).not.toBeCalled();
-          expect(eventBridge.emit).not.toBeCalled();
-        });
-      });
-    });
-  });
-});

+ 0 - 40
public/app/features/explore/state/epics/processQueryErrorsEpic.ts

@@ -1,40 +0,0 @@
-import { Epic } from 'redux-observable';
-import { mergeMap } from 'rxjs/operators';
-import { NEVER, of } from 'rxjs';
-
-import { ActionOf } from 'app/core/redux/actionCreatorFactory';
-import { StoreState } from 'app/types/store';
-import { instanceOfDataQueryError } from 'app/core/utils/explore';
-import { toDataQueryError } from 'app/features/dashboard/state/PanelQueryState';
-import { processQueryErrorsAction, ProcessQueryErrorsPayload, queryFailureAction } from '../actionTypes';
-
-export const processQueryErrorsEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState> = (action$, state$) => {
-  return action$.ofType(processQueryErrorsAction.type).pipe(
-    mergeMap((action: ActionOf<ProcessQueryErrorsPayload>) => {
-      const { exploreId, datasourceId } = action.payload;
-      let { response } = action.payload;
-      const { datasourceInstance, eventBridge } = state$.value.explore[exploreId];
-
-      if (datasourceInstance.meta.id !== datasourceId || response.cancelled) {
-        // Navigated away, queries did not matter
-        return NEVER;
-      }
-
-      // For Angular editors
-      eventBridge.emit('data-error', response);
-
-      console.error(response); // To help finding problems with query syntax
-
-      if (!instanceOfDataQueryError(response)) {
-        response = toDataQueryError(response);
-      }
-
-      return of(
-        queryFailureAction({
-          exploreId,
-          response,
-        })
-      );
-    })
-  );
-};

+ 0 - 119
public/app/features/explore/state/epics/processQueryResultsEpic.test.ts

@@ -1,119 +0,0 @@
-import { mockExploreState } from 'test/mocks/mockExploreState';
-import { epicTester, MOCKED_ABSOLUTE_RANGE } from 'test/core/redux/epicTester';
-import {
-  processQueryResultsAction,
-  resetQueryErrorAction,
-  querySuccessAction,
-  scanStopAction,
-  updateTimeRangeAction,
-  runQueriesAction,
-} from '../actionTypes';
-import { DataFrame, LoadingState, toDataFrame } from '@grafana/data';
-import { processQueryResultsEpic } from './processQueryResultsEpic';
-import TableModel from 'app/core/table_model';
-
-const testContext = () => {
-  const serieA: DataFrame = toDataFrame({
-    fields: [],
-    refId: 'A',
-  });
-  const serieB: DataFrame = toDataFrame({
-    fields: [],
-    refId: 'B',
-  });
-  const series = [serieA, serieB];
-  const latency = 0;
-  const loadingState = LoadingState.Done;
-
-  return {
-    latency,
-    series,
-    loadingState,
-  };
-};
-
-describe('processQueryResultsEpic', () => {
-  describe('when processQueryResultsAction is dispatched', () => {
-    describe('and datasourceInstance is the same', () => {
-      describe('and explore is not scanning', () => {
-        it('then resetQueryErrorAction and querySuccessAction are dispatched and eventBridge emits correct message', () => {
-          const { datasourceId, exploreId, state, eventBridge } = mockExploreState();
-          const { latency, series, loadingState } = testContext();
-          const graphResult: any[] = [];
-          const tableResult = new TableModel();
-          const logsResult: any = null;
-
-          epicTester(processQueryResultsEpic, state)
-            .whenActionIsDispatched(
-              processQueryResultsAction({ exploreId, datasourceId, loadingState, series, latency })
-            )
-            .thenResultingActionsEqual(
-              resetQueryErrorAction({ exploreId, refIds: ['A', 'B'] }),
-              querySuccessAction({ exploreId, loadingState, graphResult, tableResult, logsResult, latency })
-            );
-
-          expect(eventBridge.emit).toBeCalledTimes(1);
-          expect(eventBridge.emit).toBeCalledWith('data-received', series);
-        });
-      });
-
-      describe('and explore is scanning', () => {
-        describe('and we have a result', () => {
-          it('then correct actions are dispatched', () => {
-            const { datasourceId, exploreId, state } = mockExploreState({ scanning: true });
-            const { latency, series, loadingState } = testContext();
-            const graphResult: any[] = [];
-            const tableResult = new TableModel();
-            const logsResult: any = null;
-
-            epicTester(processQueryResultsEpic, state)
-              .whenActionIsDispatched(
-                processQueryResultsAction({ exploreId, datasourceId, loadingState, series, latency })
-              )
-              .thenResultingActionsEqual(
-                resetQueryErrorAction({ exploreId, refIds: ['A', 'B'] }),
-                querySuccessAction({ exploreId, loadingState, graphResult, tableResult, logsResult, latency }),
-                scanStopAction({ exploreId })
-              );
-          });
-        });
-
-        describe('and we do not have a result', () => {
-          it('then correct actions are dispatched', () => {
-            const { datasourceId, exploreId, state } = mockExploreState({ scanning: true });
-            const { latency, loadingState } = testContext();
-            const graphResult: any[] = [];
-            const tableResult = new TableModel();
-            const logsResult: any = null;
-
-            epicTester(processQueryResultsEpic, state)
-              .whenActionIsDispatched(
-                processQueryResultsAction({ exploreId, datasourceId, loadingState, series: [], latency })
-              )
-              .thenResultingActionsEqual(
-                resetQueryErrorAction({ exploreId, refIds: [] }),
-                querySuccessAction({ exploreId, loadingState, graphResult, tableResult, logsResult, latency }),
-                updateTimeRangeAction({ exploreId, absoluteRange: MOCKED_ABSOLUTE_RANGE }),
-                runQueriesAction({ exploreId })
-              );
-          });
-        });
-      });
-    });
-
-    describe('and datasourceInstance is not the same', () => {
-      it('then no actions are dispatched and eventBridge does not emit message', () => {
-        const { exploreId, state, eventBridge } = mockExploreState();
-        const { series, loadingState } = testContext();
-
-        epicTester(processQueryResultsEpic, state)
-          .whenActionIsDispatched(
-            processQueryResultsAction({ exploreId, datasourceId: 'other id', loadingState, series, latency: 0 })
-          )
-          .thenNoActionsWhereDispatched();
-
-        expect(eventBridge.emit).not.toBeCalled();
-      });
-    });
-  });
-});

+ 0 - 82
public/app/features/explore/state/epics/processQueryResultsEpic.ts

@@ -1,82 +0,0 @@
-import _ from 'lodash';
-import { Epic } from 'redux-observable';
-import { mergeMap } from 'rxjs/operators';
-import { NEVER } from 'rxjs';
-import { LoadingState } from '@grafana/data';
-
-import { ActionOf } from 'app/core/redux/actionCreatorFactory';
-import { StoreState } from 'app/types/store';
-import { getRefIds } from 'app/core/utils/explore';
-import {
-  processQueryResultsAction,
-  ProcessQueryResultsPayload,
-  querySuccessAction,
-  resetQueryErrorAction,
-  scanStopAction,
-  updateTimeRangeAction,
-  runQueriesAction,
-} from '../actionTypes';
-import { ResultProcessor } from '../../utils/ResultProcessor';
-
-export const processQueryResultsEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState> = (
-  action$,
-  state$,
-  { getTimeZone, getShiftedTimeRange }
-) => {
-  return action$.ofType(processQueryResultsAction.type).pipe(
-    mergeMap((action: ActionOf<ProcessQueryResultsPayload>) => {
-      const { exploreId, datasourceId, latency, loadingState, series, delta } = action.payload;
-      const { datasourceInstance, scanning, eventBridge } = state$.value.explore[exploreId];
-
-      // If datasource already changed, results do not matter
-      if (datasourceInstance.meta.id !== datasourceId) {
-        return NEVER;
-      }
-
-      const result = series || delta || [];
-      const replacePreviousResults = loadingState === LoadingState.Done && series && !delta ? true : false;
-      const resultProcessor = new ResultProcessor(state$.value.explore[exploreId], replacePreviousResults, result);
-      const graphResult = resultProcessor.getGraphResult();
-      const tableResult = resultProcessor.getTableResult();
-      const logsResult = resultProcessor.getLogsResult();
-      const refIds = getRefIds(result);
-      const actions: Array<ActionOf<any>> = [];
-
-      // For Angular editors
-      eventBridge.emit('data-received', resultProcessor.getRawData());
-
-      // Clears any previous errors that now have a successful query, important so Angular editors are updated correctly
-      actions.push(
-        resetQueryErrorAction({
-          exploreId,
-          refIds,
-        })
-      );
-
-      actions.push(
-        querySuccessAction({
-          exploreId,
-          latency,
-          loadingState,
-          graphResult,
-          tableResult,
-          logsResult,
-        })
-      );
-
-      // Keep scanning for results if this was the last scanning transaction
-      if (scanning) {
-        if (_.size(result) === 0) {
-          const range = getShiftedTimeRange(-1, state$.value.explore[exploreId].range, getTimeZone(state$.value.user));
-          actions.push(updateTimeRangeAction({ exploreId, absoluteRange: range }));
-          actions.push(runQueriesAction({ exploreId }));
-        } else {
-          // We can stop scanning if we have a result
-          actions.push(scanStopAction({ exploreId }));
-        }
-      }
-
-      return actions;
-    })
-  );
-};

+ 0 - 425
public/app/features/explore/state/epics/runQueriesBatchEpic.test.ts

@@ -1,425 +0,0 @@
-import { mockExploreState } from 'test/mocks/mockExploreState';
-import { epicTester } from 'test/core/redux/epicTester';
-import { runQueriesBatchEpic } from './runQueriesBatchEpic';
-import {
-  runQueriesBatchAction,
-  queryStartAction,
-  historyUpdatedAction,
-  processQueryResultsAction,
-  processQueryErrorsAction,
-  limitMessageRatePayloadAction,
-  resetExploreAction,
-  updateDatasourceInstanceAction,
-  changeRefreshIntervalAction,
-  clearQueriesAction,
-  stateSaveAction,
-} from '../actionTypes';
-import { LoadingState, DataFrame, FieldType, DataFrameHelper } from '@grafana/data';
-import { DataQueryRequest } from '@grafana/ui';
-
-const testContext = () => {
-  const series: DataFrame[] = [
-    new DataFrameHelper({
-      fields: [
-        {
-          name: 'Value',
-          values: [],
-        },
-        {
-          name: 'Time',
-          type: FieldType.time,
-          config: {
-            unit: 'dateTimeAsIso',
-          },
-          values: [],
-        },
-      ],
-      refId: 'A',
-    }),
-  ];
-  const response = { data: series };
-
-  return {
-    response,
-    series,
-  };
-};
-
-describe('runQueriesBatchEpic', () => {
-  let originalDateNow = Date.now;
-  beforeEach(() => {
-    originalDateNow = Date.now;
-    Date.now = () => 1337;
-  });
-
-  afterEach(() => {
-    Date.now = originalDateNow;
-  });
-
-  describe('when runQueriesBatchAction is dispatched', () => {
-    describe('and query targets are not live', () => {
-      describe('and query is successful', () => {
-        it('then correct actions are dispatched', () => {
-          const { response, series } = testContext();
-          const { exploreId, state, history, datasourceId } = mockExploreState();
-
-          epicTester(runQueriesBatchEpic, state)
-            .whenActionIsDispatched(
-              runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } })
-            )
-            .whenQueryReceivesResponse(response)
-            .thenResultingActionsEqual(
-              queryStartAction({ exploreId }),
-              historyUpdatedAction({ exploreId, history }),
-              processQueryResultsAction({
-                exploreId,
-                delta: null,
-                series,
-                latency: 0,
-                datasourceId,
-                loadingState: LoadingState.Done,
-              }),
-              stateSaveAction()
-            );
-        });
-      });
-
-      describe('and query is not successful', () => {
-        it('then correct actions are dispatched', () => {
-          const error = {
-            message: 'Error parsing line x',
-          };
-          const { exploreId, state, datasourceId } = mockExploreState();
-
-          epicTester(runQueriesBatchEpic, state)
-            .whenActionIsDispatched(
-              runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } })
-            )
-            .whenQueryThrowsError(error)
-            .thenResultingActionsEqual(
-              queryStartAction({ exploreId }),
-              processQueryErrorsAction({ exploreId, response: error, datasourceId })
-            );
-        });
-      });
-    });
-
-    describe('and query targets are live', () => {
-      describe('and state equals Streaming', () => {
-        it('then correct actions are dispatched', () => {
-          const { exploreId, state, datasourceId } = mockExploreState();
-          const unsubscribe = jest.fn();
-          const serieA: any = {
-            fields: [],
-            rows: [],
-            refId: 'A',
-          };
-          const serieB: any = {
-            fields: [],
-            rows: [],
-            refId: 'B',
-          };
-
-          epicTester(runQueriesBatchEpic, state)
-            .whenActionIsDispatched(
-              runQueriesBatchAction({ exploreId, queryOptions: { live: true, interval: '', maxDataPoints: 1980 } })
-            )
-            .whenQueryObserverReceivesEvent({
-              state: LoadingState.Streaming,
-              delta: [serieA],
-              key: 'some key',
-              request: {} as DataQueryRequest,
-              unsubscribe,
-            })
-            .whenQueryObserverReceivesEvent({
-              state: LoadingState.Streaming,
-              delta: [serieB],
-              key: 'some key',
-              request: {} as DataQueryRequest,
-              unsubscribe,
-            })
-            .thenResultingActionsEqual(
-              queryStartAction({ exploreId }),
-              limitMessageRatePayloadAction({ exploreId, series: [serieA], datasourceId }),
-              limitMessageRatePayloadAction({ exploreId, series: [serieB], datasourceId })
-            );
-        });
-      });
-
-      describe('and state equals Error', () => {
-        it('then correct actions are dispatched', () => {
-          const { exploreId, state, datasourceId } = mockExploreState();
-          const unsubscribe = jest.fn();
-          const error = { message: 'Something went really wrong!' };
-
-          epicTester(runQueriesBatchEpic, state)
-            .whenActionIsDispatched(
-              runQueriesBatchAction({ exploreId, queryOptions: { live: true, interval: '', maxDataPoints: 1980 } })
-            )
-            .whenQueryObserverReceivesEvent({
-              state: LoadingState.Error,
-              error,
-              key: 'some key',
-              request: {} as DataQueryRequest,
-              unsubscribe,
-            })
-            .thenResultingActionsEqual(
-              queryStartAction({ exploreId }),
-              processQueryErrorsAction({ exploreId, response: error, datasourceId })
-            );
-        });
-      });
-
-      describe('and state equals Done', () => {
-        it('then correct actions are dispatched', () => {
-          const { exploreId, state, datasourceId, history } = mockExploreState();
-          const unsubscribe = jest.fn();
-          const serieA: any = {
-            fields: [],
-            rows: [],
-            refId: 'A',
-          };
-          const serieB: any = {
-            fields: [],
-            rows: [],
-            refId: 'B',
-          };
-          const delta = [serieA, serieB];
-
-          epicTester(runQueriesBatchEpic, state)
-            .whenActionIsDispatched(
-              runQueriesBatchAction({ exploreId, queryOptions: { live: true, interval: '', maxDataPoints: 1980 } })
-            )
-            .whenQueryObserverReceivesEvent({
-              state: LoadingState.Done,
-              data: null,
-              delta,
-              key: 'some key',
-              request: {} as DataQueryRequest,
-              unsubscribe,
-            })
-            .thenResultingActionsEqual(
-              queryStartAction({ exploreId }),
-              historyUpdatedAction({ exploreId, history }),
-              processQueryResultsAction({
-                exploreId,
-                delta,
-                series: null,
-                latency: 0,
-                datasourceId,
-                loadingState: LoadingState.Done,
-              }),
-              stateSaveAction()
-            );
-        });
-      });
-    });
-
-    describe('and another runQueriesBatchAction is dispatched', () => {
-      it('then the observable should be unsubscribed', () => {
-        const { response, series } = testContext();
-        const { exploreId, state, history, datasourceId } = mockExploreState();
-        const unsubscribe = jest.fn();
-
-        epicTester(runQueriesBatchEpic, state)
-          .whenActionIsDispatched(
-            runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } }) // first observable
-          )
-          .whenQueryReceivesResponse(response)
-          .whenQueryObserverReceivesEvent({
-            key: 'some key',
-            request: {} as DataQueryRequest,
-            state: LoadingState.Loading, // fake just to setup and test unsubscribe
-            unsubscribe,
-          })
-          .whenActionIsDispatched(
-            // second observable and unsubscribes the first observable
-            runQueriesBatchAction({ exploreId, queryOptions: { live: true, interval: '', maxDataPoints: 800 } })
-          )
-          .whenQueryReceivesResponse(response)
-          .whenQueryObserverReceivesEvent({
-            key: 'some key',
-            request: {} as DataQueryRequest,
-            state: LoadingState.Loading, // fake just to setup and test unsubscribe
-            unsubscribe,
-          })
-          .thenResultingActionsEqual(
-            queryStartAction({ exploreId }), // output from first observable
-            historyUpdatedAction({ exploreId, history }), // output from first observable
-            processQueryResultsAction({
-              exploreId,
-              delta: null,
-              series,
-              latency: 0,
-              datasourceId,
-              loadingState: LoadingState.Done,
-            }),
-            stateSaveAction(),
-            // output from first observable
-            queryStartAction({ exploreId }), // output from second observable
-            historyUpdatedAction({ exploreId, history }), // output from second observable
-            processQueryResultsAction({
-              exploreId,
-              delta: null,
-              series,
-              latency: 0,
-              datasourceId,
-              loadingState: LoadingState.Done,
-            }),
-            stateSaveAction()
-            // output from second observable
-          );
-
-        expect(unsubscribe).toBeCalledTimes(1); // first unsubscribe should be called but not second as that isn't unsubscribed
-      });
-    });
-
-    describe('and resetExploreAction is dispatched', () => {
-      it('then the observable should be unsubscribed', () => {
-        const { response, series } = testContext();
-        const { exploreId, state, history, datasourceId } = mockExploreState();
-        const unsubscribe = jest.fn();
-
-        epicTester(runQueriesBatchEpic, state)
-          .whenActionIsDispatched(
-            runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } })
-          )
-          .whenQueryReceivesResponse(response)
-          .whenQueryObserverReceivesEvent({
-            key: 'some key',
-            request: {} as DataQueryRequest,
-            state: LoadingState.Loading, // fake just to setup and test unsubscribe
-            unsubscribe,
-          })
-          .whenActionIsDispatched(resetExploreAction()) // unsubscribes the observable
-          .whenQueryReceivesResponse(response) // new updates will not reach anywhere
-          .thenResultingActionsEqual(
-            queryStartAction({ exploreId }),
-            historyUpdatedAction({ exploreId, history }),
-            processQueryResultsAction({
-              exploreId,
-              delta: null,
-              series,
-              latency: 0,
-              datasourceId,
-              loadingState: LoadingState.Done,
-            }),
-            stateSaveAction()
-          );
-
-        expect(unsubscribe).toBeCalledTimes(1);
-      });
-    });
-
-    describe('and updateDatasourceInstanceAction is dispatched', () => {
-      it('then the observable should be unsubscribed', () => {
-        const { response, series } = testContext();
-        const { exploreId, state, history, datasourceId, datasourceInstance } = mockExploreState();
-        const unsubscribe = jest.fn();
-
-        epicTester(runQueriesBatchEpic, state)
-          .whenActionIsDispatched(
-            runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } })
-          )
-          .whenQueryReceivesResponse(response)
-          .whenQueryObserverReceivesEvent({
-            key: 'some key',
-            request: {} as DataQueryRequest,
-            state: LoadingState.Loading, // fake just to setup and test unsubscribe
-            unsubscribe,
-          })
-          .whenActionIsDispatched(updateDatasourceInstanceAction({ exploreId, datasourceInstance })) // unsubscribes the observable
-          .whenQueryReceivesResponse(response) // new updates will not reach anywhere
-          .thenResultingActionsEqual(
-            queryStartAction({ exploreId }),
-            historyUpdatedAction({ exploreId, history }),
-            processQueryResultsAction({
-              exploreId,
-              delta: null,
-              series,
-              latency: 0,
-              datasourceId,
-              loadingState: LoadingState.Done,
-            }),
-            stateSaveAction()
-          );
-
-        expect(unsubscribe).toBeCalledTimes(1);
-      });
-    });
-
-    describe('and changeRefreshIntervalAction is dispatched', () => {
-      it('then the observable should be unsubscribed', () => {
-        const { response, series } = testContext();
-        const { exploreId, state, history, datasourceId } = mockExploreState();
-        const unsubscribe = jest.fn();
-
-        epicTester(runQueriesBatchEpic, state)
-          .whenActionIsDispatched(
-            runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } })
-          )
-          .whenQueryReceivesResponse(response)
-          .whenQueryObserverReceivesEvent({
-            key: 'some key',
-            request: {} as DataQueryRequest,
-            state: LoadingState.Loading, // fake just to setup and test unsubscribe
-            unsubscribe,
-          })
-          .whenActionIsDispatched(changeRefreshIntervalAction({ exploreId, refreshInterval: '' })) // unsubscribes the observable
-          .whenQueryReceivesResponse(response) // new updates will not reach anywhere
-          .thenResultingActionsEqual(
-            queryStartAction({ exploreId }),
-            historyUpdatedAction({ exploreId, history }),
-            processQueryResultsAction({
-              exploreId,
-              delta: null,
-              series,
-              latency: 0,
-              datasourceId,
-              loadingState: LoadingState.Done,
-            }),
-            stateSaveAction()
-          );
-
-        expect(unsubscribe).toBeCalledTimes(1);
-      });
-    });
-
-    describe('and clearQueriesAction is dispatched', () => {
-      it('then the observable should be unsubscribed', () => {
-        const { response, series } = testContext();
-        const { exploreId, state, history, datasourceId } = mockExploreState();
-        const unsubscribe = jest.fn();
-
-        epicTester(runQueriesBatchEpic, state)
-          .whenActionIsDispatched(
-            runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } })
-          )
-          .whenQueryReceivesResponse(response)
-          .whenQueryObserverReceivesEvent({
-            key: 'some key',
-            request: {} as DataQueryRequest,
-            state: LoadingState.Loading, // fake just to setup and test unsubscribe
-            unsubscribe,
-          })
-          .whenActionIsDispatched(clearQueriesAction({ exploreId })) // unsubscribes the observable
-          .whenQueryReceivesResponse(response) // new updates will not reach anywhere
-          .thenResultingActionsEqual(
-            queryStartAction({ exploreId }),
-            historyUpdatedAction({ exploreId, history }),
-            processQueryResultsAction({
-              exploreId,
-              delta: null,
-              series,
-              latency: 0,
-              datasourceId,
-              loadingState: LoadingState.Done,
-            }),
-            stateSaveAction()
-          );
-
-        expect(unsubscribe).toBeCalledTimes(1);
-      });
-    });
-  });
-});

+ 0 - 231
public/app/features/explore/state/epics/runQueriesBatchEpic.ts

@@ -1,231 +0,0 @@
-import { Epic } from 'redux-observable';
-import { Observable, Subject } from 'rxjs';
-import { mergeMap, catchError, takeUntil, filter } from 'rxjs/operators';
-import _, { isString } from 'lodash';
-import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker';
-import { DataStreamState, DataQueryResponse, DataQueryResponseData } from '@grafana/ui';
-
-import { LoadingState, DataFrame, AbsoluteTimeRange } from '@grafana/data';
-import { dateMath } from '@grafana/data';
-
-import { ActionOf } from 'app/core/redux/actionCreatorFactory';
-import { StoreState } from 'app/types/store';
-import { buildQueryTransaction, updateHistory } from 'app/core/utils/explore';
-import {
-  clearQueriesAction,
-  historyUpdatedAction,
-  resetExploreAction,
-  updateDatasourceInstanceAction,
-  changeRefreshIntervalAction,
-  processQueryErrorsAction,
-  processQueryResultsAction,
-  runQueriesBatchAction,
-  RunQueriesBatchPayload,
-  queryStartAction,
-  limitMessageRatePayloadAction,
-  stateSaveAction,
-  changeRangeAction,
-} from '../actionTypes';
-import { ExploreId, ExploreItemState } from 'app/types';
-
-const publishActions = (outerObservable: Subject<any>, actions: Array<ActionOf<any>>) => {
-  for (const action of actions) {
-    outerObservable.next(action);
-  }
-};
-
-interface ProcessResponseConfig {
-  exploreId: ExploreId;
-  exploreItemState: ExploreItemState;
-  datasourceId: string;
-  now: number;
-  loadingState: LoadingState;
-  series?: DataQueryResponseData[];
-  delta?: DataFrame[];
-}
-
-const processResponse = (config: ProcessResponseConfig) => {
-  const { exploreId, exploreItemState, datasourceId, now, loadingState, series, delta } = config;
-  const { queries, history } = exploreItemState;
-  const latency = Date.now() - now;
-
-  // Side-effect: Saving history in localstorage
-  const nextHistory = updateHistory(history, datasourceId, queries);
-  return [
-    historyUpdatedAction({ exploreId, history: nextHistory }),
-    processQueryResultsAction({ exploreId, latency, datasourceId, loadingState, series, delta }),
-    stateSaveAction(),
-  ];
-};
-
-interface ProcessErrorConfig {
-  exploreId: ExploreId;
-  datasourceId: string;
-  error: any;
-}
-
-const processError = (config: ProcessErrorConfig) => {
-  const { exploreId, datasourceId, error } = config;
-
-  return [processQueryErrorsAction({ exploreId, response: error, datasourceId })];
-};
-
-export const runQueriesBatchEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState> = (
-  action$,
-  state$,
-  { getQueryResponse }
-) => {
-  return action$.ofType(runQueriesBatchAction.type).pipe(
-    mergeMap((action: ActionOf<RunQueriesBatchPayload>) => {
-      const { exploreId, queryOptions } = action.payload;
-      const exploreItemState = state$.value.explore[exploreId];
-      const { datasourceInstance, queries, queryIntervals, range, scanning } = exploreItemState;
-
-      // Create an observable per run queries action
-      // Within the observable create two subscriptions
-      // First subscription: 'querySubscription' subscribes to the call to query method on datasourceinstance
-      // Second subscription: 'streamSubscription' subscribes to events from the query methods observer callback
-      const observable: Observable<ActionOf<any>> = Observable.create((outerObservable: Subject<any>) => {
-        const datasourceId = datasourceInstance.meta.id;
-        const transaction = buildQueryTransaction(queries, queryOptions, range, queryIntervals, scanning);
-        outerObservable.next(queryStartAction({ exploreId }));
-
-        const now = Date.now();
-        let datasourceUnsubscribe: Function = null;
-        const streamHandler = new Subject<DataStreamState>();
-        const observer = (event: DataStreamState) => {
-          datasourceUnsubscribe = event.unsubscribe;
-          if (!streamHandler.closed) {
-            // their might be a race condition when unsubscribing
-            streamHandler.next(event);
-          }
-        };
-
-        // observer subscription, handles datasourceInstance.query observer events and pushes that forward
-        const streamSubscription = streamHandler.subscribe({
-          next: event => {
-            const { state, error, data, delta } = event;
-            if (!data && !delta && !error) {
-              return;
-            }
-
-            if (state === LoadingState.Error) {
-              const actions = processError({ exploreId, datasourceId, error });
-              publishActions(outerObservable, actions);
-            }
-
-            if (state === LoadingState.Streaming) {
-              if (event.request && event.request.range) {
-                let newRange = event.request.range;
-                let absoluteRange: AbsoluteTimeRange = {
-                  from: newRange.from.valueOf(),
-                  to: newRange.to.valueOf(),
-                };
-                if (isString(newRange.raw.from)) {
-                  newRange = {
-                    from: dateMath.parse(newRange.raw.from, false),
-                    to: dateMath.parse(newRange.raw.to, true),
-                    raw: newRange.raw,
-                  };
-                  absoluteRange = {
-                    from: newRange.from.valueOf(),
-                    to: newRange.to.valueOf(),
-                  };
-                }
-                outerObservable.next(changeRangeAction({ exploreId, range: newRange, absoluteRange }));
-              }
-
-              outerObservable.next(
-                limitMessageRatePayloadAction({
-                  exploreId,
-                  series: delta,
-                  datasourceId,
-                })
-              );
-            }
-
-            if (state === LoadingState.Done || state === LoadingState.Loading) {
-              const actions = processResponse({
-                exploreId,
-                exploreItemState,
-                datasourceId,
-                now,
-                loadingState: state,
-                series: null,
-                delta,
-              });
-              publishActions(outerObservable, actions);
-            }
-          },
-        });
-
-        // query subscription, handles datasourceInstance.query response and pushes that forward
-        const querySubscription = getQueryResponse(datasourceInstance, transaction.options, observer)
-          .pipe(
-            mergeMap((response: DataQueryResponse) => {
-              return processResponse({
-                exploreId,
-                exploreItemState,
-                datasourceId,
-                now,
-                loadingState: LoadingState.Done,
-                series: response && response.data ? response.data : [],
-                delta: null,
-              });
-            }),
-            catchError(error => {
-              return processError({ exploreId, datasourceId, error });
-            })
-          )
-          .subscribe({ next: (action: ActionOf<any>) => outerObservable.next(action) });
-
-        // this unsubscribe method will be called when any of the takeUntil actions below happen
-        const unsubscribe = () => {
-          if (datasourceUnsubscribe) {
-            datasourceUnsubscribe();
-          }
-          querySubscription.unsubscribe();
-          streamSubscription.unsubscribe();
-          streamHandler.unsubscribe();
-          outerObservable.unsubscribe();
-        };
-
-        return unsubscribe;
-      });
-
-      return observable.pipe(
-        takeUntil(
-          action$
-            .ofType(
-              runQueriesBatchAction.type,
-              resetExploreAction.type,
-              updateDatasourceInstanceAction.type,
-              changeRefreshIntervalAction.type,
-              clearQueriesAction.type
-            )
-            .pipe(
-              filter(action => {
-                if (action.type === resetExploreAction.type) {
-                  return true; // stops all subscriptions if user navigates away
-                }
-
-                if (action.type === updateDatasourceInstanceAction.type && action.payload.exploreId === exploreId) {
-                  return true; // stops subscriptions if user changes data source
-                }
-
-                if (action.type === changeRefreshIntervalAction.type && action.payload.exploreId === exploreId) {
-                  return !isLive(action.payload.refreshInterval); // stops subscriptions if user changes refresh interval away from 'Live'
-                }
-
-                if (action.type === clearQueriesAction.type && action.payload.exploreId === exploreId) {
-                  return true; // stops subscriptions if user clears all queries
-                }
-
-                return action.payload.exploreId === exploreId;
-              })
-            )
-        )
-      );
-    })
-  );
-};

+ 0 - 71
public/app/features/explore/state/epics/runQueriesEpic.test.ts

@@ -1,71 +0,0 @@
-import { mockExploreState } from 'test/mocks/mockExploreState';
-import { epicTester } from 'test/core/redux/epicTester';
-import { runQueriesAction, stateSaveAction, runQueriesBatchAction, clearQueriesAction } from '../actionTypes';
-import { runQueriesEpic } from './runQueriesEpic';
-
-describe('runQueriesEpic', () => {
-  describe('when runQueriesAction is dispatched', () => {
-    describe('and there is no datasourceError', () => {
-      describe('and we have non empty queries', () => {
-        describe('and explore is not live', () => {
-          it('then runQueriesBatchAction and stateSaveAction are dispatched', () => {
-            const queries = [{ refId: 'A', key: '123456', expr: '{__filename__="some.log"}' }];
-            const { exploreId, state, datasourceInterval, containerWidth } = mockExploreState({ queries });
-
-            epicTester(runQueriesEpic, state)
-              .whenActionIsDispatched(runQueriesAction({ exploreId }))
-              .thenResultingActionsEqual(
-                runQueriesBatchAction({
-                  exploreId,
-                  queryOptions: { interval: datasourceInterval, maxDataPoints: containerWidth, live: false },
-                })
-              );
-          });
-        });
-
-        describe('and explore is live', () => {
-          it('then runQueriesBatchAction and stateSaveAction are dispatched', () => {
-            const queries = [{ refId: 'A', key: '123456', expr: '{__filename__="some.log"}' }];
-            const { exploreId, state, datasourceInterval, containerWidth } = mockExploreState({
-              queries,
-              isLive: true,
-              streaming: true,
-            });
-
-            epicTester(runQueriesEpic, state)
-              .whenActionIsDispatched(runQueriesAction({ exploreId }))
-              .thenResultingActionsEqual(
-                runQueriesBatchAction({
-                  exploreId,
-                  queryOptions: { interval: datasourceInterval, maxDataPoints: containerWidth, live: true },
-                })
-              );
-          });
-        });
-      });
-
-      describe('and we have no queries', () => {
-        it('then clearQueriesAction and stateSaveAction are dispatched', () => {
-          const queries: any[] = [];
-          const { exploreId, state } = mockExploreState({ queries });
-
-          epicTester(runQueriesEpic, state)
-            .whenActionIsDispatched(runQueriesAction({ exploreId }))
-            .thenResultingActionsEqual(clearQueriesAction({ exploreId }), stateSaveAction());
-        });
-      });
-    });
-
-    describe('and there is a datasourceError', () => {
-      it('then no actions are dispatched', () => {
-        const { exploreId, state } = mockExploreState({
-          datasourceError: { message: 'Some error' },
-        });
-
-        epicTester(runQueriesEpic, state)
-          .whenActionIsDispatched(runQueriesAction({ exploreId }))
-          .thenNoActionsWhereDispatched();
-      });
-    });
-  });
-});

+ 0 - 39
public/app/features/explore/state/epics/runQueriesEpic.ts

@@ -1,39 +0,0 @@
-import { Epic } from 'redux-observable';
-import { NEVER } from 'rxjs';
-import { mergeMap } from 'rxjs/operators';
-
-import { ActionOf } from 'app/core/redux/actionCreatorFactory';
-import { StoreState } from 'app/types/store';
-import { hasNonEmptyQuery } from 'app/core/utils/explore';
-import {
-  clearQueriesAction,
-  runQueriesAction,
-  RunQueriesPayload,
-  runQueriesBatchAction,
-  stateSaveAction,
-} from '../actionTypes';
-
-export const runQueriesEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState> = (action$, state$) => {
-  return action$.ofType(runQueriesAction.type).pipe(
-    mergeMap((action: ActionOf<RunQueriesPayload>) => {
-      const { exploreId } = action.payload;
-      const { datasourceInstance, queries, datasourceError, containerWidth, isLive } = state$.value.explore[exploreId];
-
-      if (datasourceError) {
-        // let's not run any queries if data source is in a faulty state
-        return NEVER;
-      }
-
-      if (!hasNonEmptyQuery(queries)) {
-        return [clearQueriesAction({ exploreId }), stateSaveAction()]; // Remember to save to state and update location
-      }
-
-      // Some datasource's query builders allow per-query interval limits,
-      // but we're using the datasource interval limit for now
-      const interval = datasourceInstance.interval;
-      const live = isLive;
-
-      return [runQueriesBatchAction({ exploreId, queryOptions: { interval, maxDataPoints: containerWidth, live } })];
-    })
-  );
-};

+ 0 - 62
public/app/features/explore/state/epics/stateSaveEpic.test.ts

@@ -1,62 +0,0 @@
-import { epicTester } from 'test/core/redux/epicTester';
-import { stateSaveEpic } from './stateSaveEpic';
-import { stateSaveAction, setUrlReplacedAction } from '../actionTypes';
-import { updateLocation } from 'app/core/actions/location';
-import { mockExploreState } from 'test/mocks/mockExploreState';
-
-describe('stateSaveEpic', () => {
-  describe('when stateSaveAction is dispatched', () => {
-    describe('and there is a left state', () => {
-      describe('and no split', () => {
-        it('then the correct actions are dispatched', () => {
-          const { exploreId, state } = mockExploreState();
-
-          epicTester(stateSaveEpic, state)
-            .whenActionIsDispatched(stateSaveAction())
-            .thenResultingActionsEqual(
-              updateLocation({
-                query: { orgId: '1', left: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]' },
-                replace: true,
-              }),
-              setUrlReplacedAction({ exploreId })
-            );
-        });
-      });
-
-      describe('and explore is split', () => {
-        it('then the correct actions are dispatched', () => {
-          const { exploreId, state } = mockExploreState({ split: true });
-
-          epicTester(stateSaveEpic, state)
-            .whenActionIsDispatched(stateSaveAction())
-            .thenResultingActionsEqual(
-              updateLocation({
-                query: {
-                  orgId: '1',
-                  left: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]',
-                  right: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]',
-                },
-                replace: true,
-              }),
-              setUrlReplacedAction({ exploreId })
-            );
-        });
-      });
-    });
-
-    describe('and urlReplaced is true', () => {
-      it('then setUrlReplacedAction should not be dispatched', () => {
-        const { state } = mockExploreState({ urlReplaced: true });
-
-        epicTester(stateSaveEpic, state)
-          .whenActionIsDispatched(stateSaveAction())
-          .thenResultingActionsEqual(
-            updateLocation({
-              query: { orgId: '1', left: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]' },
-              replace: false,
-            })
-          );
-      });
-    });
-  });
-});

+ 0 - 75
public/app/features/explore/state/epics/stateSaveEpic.ts

@@ -1,75 +0,0 @@
-import { Epic } from 'redux-observable';
-import { mergeMap } from 'rxjs/operators';
-import { RawTimeRange, TimeRange } from '@grafana/data';
-import { isDateTime } from '@grafana/data';
-
-import { ActionOf } from 'app/core/redux/actionCreatorFactory';
-import { StoreState } from 'app/types/store';
-import { ExploreUrlState, ExploreId } from 'app/types/explore';
-import { clearQueryKeys, serializeStateToUrlParam } from 'app/core/utils/explore';
-import { updateLocation } from 'app/core/actions/location';
-import { setUrlReplacedAction, stateSaveAction } from '../actionTypes';
-
-const toRawTimeRange = (range: TimeRange): RawTimeRange => {
-  let from = range.raw.from;
-  if (isDateTime(from)) {
-    from = from.valueOf().toString(10);
-  }
-
-  let to = range.raw.to;
-  if (isDateTime(to)) {
-    to = to.valueOf().toString(10);
-  }
-
-  return {
-    from,
-    to,
-  };
-};
-
-export const stateSaveEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState> = (action$, state$) => {
-  return action$.ofType(stateSaveAction.type).pipe(
-    mergeMap(() => {
-      const { left, right, split } = state$.value.explore;
-      const orgId = state$.value.user.orgId.toString();
-      const replace = left && left.urlReplaced === false;
-      const urlStates: { [index: string]: string } = { orgId };
-      const leftUrlState: ExploreUrlState = {
-        datasource: left.datasourceInstance.name,
-        queries: left.queries.map(clearQueryKeys),
-        range: toRawTimeRange(left.range),
-        mode: left.mode,
-        ui: {
-          showingGraph: left.showingGraph,
-          showingLogs: true,
-          showingTable: left.showingTable,
-          dedupStrategy: left.dedupStrategy,
-        },
-      };
-      urlStates.left = serializeStateToUrlParam(leftUrlState, true);
-      if (split) {
-        const rightUrlState: ExploreUrlState = {
-          datasource: right.datasourceInstance.name,
-          queries: right.queries.map(clearQueryKeys),
-          range: toRawTimeRange(right.range),
-          mode: right.mode,
-          ui: {
-            showingGraph: right.showingGraph,
-            showingLogs: true,
-            showingTable: right.showingTable,
-            dedupStrategy: right.dedupStrategy,
-          },
-        };
-
-        urlStates.right = serializeStateToUrlParam(rightUrlState, true);
-      }
-
-      const actions: Array<ActionOf<any>> = [updateLocation({ query: urlStates, replace })];
-      if (replace) {
-        actions.push(setUrlReplacedAction({ exploreId: ExploreId.left }));
-      }
-
-      return actions;
-    })
-  );
-};

+ 0 - 105
public/app/features/explore/state/epics/timeEpic.test.ts

@@ -1,105 +0,0 @@
-import { dateTime, DefaultTimeZone } from '@grafana/data';
-
-import { epicTester } from 'test/core/redux/epicTester';
-import { mockExploreState } from 'test/mocks/mockExploreState';
-import { timeEpic } from './timeEpic';
-import { updateTimeRangeAction, changeRangeAction } from '../actionTypes';
-import { EpicDependencies } from 'app/store/configureStore';
-
-const from = dateTime('2019-01-01 10:00:00.000Z');
-const to = dateTime('2019-01-01 16:00:00.000Z');
-const rawFrom = 'now-6h';
-const rawTo = 'now';
-const rangeMock = {
-  from,
-  to,
-  raw: {
-    from: rawFrom,
-    to: rawTo,
-  },
-};
-
-describe('timeEpic', () => {
-  describe('when updateTimeRangeAction is dispatched', () => {
-    describe('and no rawRange is supplied', () => {
-      describe('and no absoluteRange is supplied', () => {
-        it('then the correct actions are dispatched', () => {
-          const { exploreId, state, range } = mockExploreState({ range: rangeMock });
-          const absoluteRange = { from: range.from.valueOf(), to: range.to.valueOf() };
-          const stateToTest = { ...state, user: { timeZone: 'browser', orgId: -1 } };
-          const getTimeRange = jest.fn().mockReturnValue(rangeMock);
-          const dependencies: Partial<EpicDependencies> = {
-            getTimeRange,
-          };
-
-          epicTester(timeEpic, stateToTest, dependencies)
-            .whenActionIsDispatched(updateTimeRangeAction({ exploreId }))
-            .thenDependencyWasCalledTimes(1, 'getTimeSrv', 'init')
-            .thenDependencyWasCalledTimes(1, 'getTimeRange')
-            .thenDependencyWasCalledWith([DefaultTimeZone, rangeMock.raw], 'getTimeRange')
-            .thenResultingActionsEqual(
-              changeRangeAction({
-                exploreId,
-                range,
-                absoluteRange,
-              })
-            );
-        });
-      });
-
-      describe('and absoluteRange is supplied', () => {
-        it('then the correct actions are dispatched', () => {
-          const { exploreId, state, range } = mockExploreState({ range: rangeMock });
-          const absoluteRange = { from: range.from.valueOf(), to: range.to.valueOf() };
-          const stateToTest = { ...state, user: { timeZone: 'browser', orgId: -1 } };
-          const getTimeRange = jest.fn().mockReturnValue(rangeMock);
-          const dependencies: Partial<EpicDependencies> = {
-            getTimeRange,
-          };
-
-          epicTester(timeEpic, stateToTest, dependencies)
-            .whenActionIsDispatched(updateTimeRangeAction({ exploreId, absoluteRange }))
-            .thenDependencyWasCalledTimes(1, 'getTimeSrv', 'init')
-            .thenDependencyWasCalledTimes(1, 'getTimeRange')
-            .thenDependencyWasCalledWith([DefaultTimeZone, { from: null, to: null }], 'getTimeRange')
-            .thenDependencyWasCalledTimes(2, 'dateTimeForTimeZone')
-            .thenResultingActionsEqual(
-              changeRangeAction({
-                exploreId,
-                range,
-                absoluteRange,
-              })
-            );
-        });
-      });
-    });
-
-    describe('and rawRange is supplied', () => {
-      describe('and no absoluteRange is supplied', () => {
-        it('then the correct actions are dispatched', () => {
-          const { exploreId, state, range } = mockExploreState({ range: rangeMock });
-          const rawRange = { from: 'now-5m', to: 'now' };
-          const absoluteRange = { from: range.from.valueOf(), to: range.to.valueOf() };
-          const stateToTest = { ...state, user: { timeZone: 'browser', orgId: -1 } };
-          const getTimeRange = jest.fn().mockReturnValue(rangeMock);
-          const dependencies: Partial<EpicDependencies> = {
-            getTimeRange,
-          };
-
-          epicTester(timeEpic, stateToTest, dependencies)
-            .whenActionIsDispatched(updateTimeRangeAction({ exploreId, rawRange }))
-            .thenDependencyWasCalledTimes(1, 'getTimeSrv', 'init')
-            .thenDependencyWasCalledTimes(1, 'getTimeRange')
-            .thenDependencyWasCalledWith([DefaultTimeZone, rawRange], 'getTimeRange')
-            .thenResultingActionsEqual(
-              changeRangeAction({
-                exploreId,
-                range,
-                absoluteRange,
-              })
-            );
-        });
-      });
-    });
-  });
-});

+ 0 - 47
public/app/features/explore/state/epics/timeEpic.ts

@@ -1,47 +0,0 @@
-import { Epic } from 'redux-observable';
-import { map } from 'rxjs/operators';
-import { AbsoluteTimeRange, RawTimeRange } from '@grafana/data';
-
-import { ActionOf } from 'app/core/redux/actionCreatorFactory';
-import { StoreState } from 'app/types/store';
-import { updateTimeRangeAction, UpdateTimeRangePayload, changeRangeAction } from '../actionTypes';
-import { EpicDependencies } from 'app/store/configureStore';
-
-export const timeEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState, EpicDependencies> = (
-  action$,
-  state$,
-  { getTimeSrv, getTimeRange, getTimeZone, dateTimeForTimeZone }
-) => {
-  return action$.ofType(updateTimeRangeAction.type).pipe(
-    map((action: ActionOf<UpdateTimeRangePayload>) => {
-      const { exploreId, absoluteRange: absRange, rawRange: actionRange } = action.payload;
-      const itemState = state$.value.explore[exploreId];
-      const timeZone = getTimeZone(state$.value.user);
-      const { range: rangeInState } = itemState;
-      let rawRange: RawTimeRange = rangeInState.raw;
-
-      if (absRange) {
-        rawRange = {
-          from: dateTimeForTimeZone(timeZone, absRange.from),
-          to: dateTimeForTimeZone(timeZone, absRange.to),
-        };
-      }
-
-      if (actionRange) {
-        rawRange = actionRange;
-      }
-
-      const range = getTimeRange(timeZone, rawRange);
-      const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
-
-      getTimeSrv().init({
-        time: range.raw,
-        refresh: false,
-        getTimezone: () => timeZone,
-        timeRangeUpdated: (): any => undefined,
-      });
-
-      return changeRangeAction({ exploreId, range, absoluteRange });
-    })
-  );
-};

+ 5 - 0
public/app/features/explore/state/reducers.test.ts

@@ -26,12 +26,14 @@ import { serializeStateToUrlParam } from 'app/core/utils/explore';
 import TableModel from 'app/core/table_model';
 import { DataSourceApi, DataQuery } from '@grafana/ui';
 import { LogsModel, LogsDedupStrategy, LoadingState } from '@grafana/data';
+import { PanelQueryState } from '../../dashboard/state/PanelQueryState';
 
 describe('Explore item reducer', () => {
   describe('scanning', () => {
     it('should start scanning', () => {
       const initalState = {
         ...makeExploreItemState(),
+        queryState: null as PanelQueryState,
         scanning: false,
       };
 
@@ -40,12 +42,14 @@ describe('Explore item reducer', () => {
         .whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left }))
         .thenStateShouldEqual({
           ...makeExploreItemState(),
+          queryState: null as PanelQueryState,
           scanning: true,
         });
     });
     it('should stop scanning', () => {
       const initalState = {
         ...makeExploreItemState(),
+        queryState: null as PanelQueryState,
         scanning: true,
         scanRange: {},
       };
@@ -55,6 +59,7 @@ describe('Explore item reducer', () => {
         .whenActionIsDispatched(scanStopAction({ exploreId: ExploreId.left }))
         .thenStateShouldEqual({
           ...makeExploreItemState(),
+          queryState: null as PanelQueryState,
           scanning: false,
           scanRange: undefined,
         });

+ 34 - 3
public/app/features/explore/state/reducers.ts

@@ -7,6 +7,7 @@ import {
   DEFAULT_UI_STATE,
   generateNewKeyAndAddRefIdIfMissing,
   sortLogsResult,
+  stopQueryState,
   refreshIntervalToSortOrder,
 } from 'app/core/utils/explore';
 import { ExploreItemState, ExploreState, ExploreId, ExploreUpdateState, ExploreMode } from 'app/types/explore';
@@ -31,9 +32,6 @@ import {
   queryStartAction,
   runQueriesAction,
   changeRangeAction,
-} from './actionTypes';
-import { reducerFactory } from 'app/core/redux';
-import {
   addQueryRowAction,
   changeQueryAction,
   changeSizeAction,
@@ -53,11 +51,15 @@ import {
   queriesImportedAction,
   updateUIStateAction,
   toggleLogLevelAction,
+  changeLoadingStateAction,
+  resetExploreAction,
 } from './actionTypes';
+import { reducerFactory } from 'app/core/redux';
 import { updateLocation } from 'app/core/actions/location';
 import { LocationUpdate } from '@grafana/runtime';
 import TableModel from 'app/core/table_model';
 import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker';
+import { PanelQueryState } from '../../dashboard/state/PanelQueryState';
 
 export const DEFAULT_RANGE = {
   from: 'now-6h',
@@ -114,6 +116,7 @@ export const makeExploreItemState = (): ExploreItemState => ({
   mode: null,
   isLive: false,
   urlReplaced: false,
+  queryState: new PanelQueryState(),
 });
 
 /**
@@ -186,6 +189,9 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       const live = isLive(refreshInterval);
       const sortOrder = refreshIntervalToSortOrder(refreshInterval);
       const logsResult = sortLogsResult(state.logsResult, sortOrder);
+      if (isLive(state.refreshInterval) && !live) {
+        stopQueryState(state.queryState, 'Live streaming stopped');
+      }
 
       return {
         ...state,
@@ -200,6 +206,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
     filter: clearQueriesAction,
     mapper: (state): ExploreItemState => {
       const queries = ensureQueries();
+      stopQueryState(state.queryState, 'Queries cleared');
       return {
         ...state,
         queries: queries.slice(),
@@ -258,6 +265,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
 
       // Custom components
       const StartPage = datasourceInstance.components.ExploreStartPage;
+      stopQueryState(state.queryState, 'Datasource changed');
 
       return {
         ...state,
@@ -577,6 +585,16 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       };
     },
   })
+  .addMapper({
+    filter: changeLoadingStateAction,
+    mapper: (state, action): ExploreItemState => {
+      const { loadingState } = action.payload;
+      return {
+        ...state,
+        loadingState,
+      };
+    },
+  })
   .create();
 
 export const updateChildRefreshState = (
@@ -664,6 +682,19 @@ export const exploreReducer = (state = initialExploreState, action: HigherOrderA
         [ExploreId.right]: updateChildRefreshState(rightState, action.payload, ExploreId.right),
       };
     }
+
+    case resetExploreAction.type: {
+      const leftState = state[ExploreId.left];
+      const rightState = state[ExploreId.right];
+      stopQueryState(leftState.queryState, 'Navigated away from Explore');
+      stopQueryState(rightState.queryState, 'Navigated away from Explore');
+
+      return {
+        ...state,
+        [ExploreId.left]: updateChildRefreshState(leftState, action.payload, ExploreId.left),
+        [ExploreId.right]: updateChildRefreshState(rightState, action.payload, ExploreId.right),
+      };
+    }
   }
 
   if (action.payload) {

+ 3 - 7
public/app/plugins/datasource/loki/datasource.ts

@@ -3,14 +3,12 @@ import _ from 'lodash';
 import { Subscription, of } from 'rxjs';
 import { webSocket } from 'rxjs/webSocket';
 import { catchError, map } from 'rxjs/operators';
-
 // Services & Utils
-import { dateMath } from '@grafana/data';
+import { dateMath, DataFrame, LogRowModel, LoadingState, DateTime } from '@grafana/data';
 import { addLabelToSelector } from 'app/plugins/datasource/prometheus/add_label_to_query';
 import LanguageProvider from './language_provider';
 import { logStreamToDataFrame } from './result_transformer';
 import { formatQuery, parseQuery, getHighlighterExpressionsFromQuery } from './query_utils';
-
 // Types
 import {
   PluginMeta,
@@ -22,8 +20,6 @@ import {
   DataStreamState,
   DataQueryResponse,
 } from '@grafana/ui';
-
-import { DataFrame, LogRowModel, LoadingState, DateTime } from '@grafana/data';
 import { LokiQuery, LokiOptions } from './types';
 import { BackendSrv } from 'app/core/services/backend_srv';
 import { TemplateSrv } from 'app/features/templating/template_srv';
@@ -179,12 +175,12 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
       const subscription = webSocket(liveTarget.url)
         .pipe(
           map((results: any[]) => {
-            const delta = this.processResult(results, liveTarget);
+            const data = this.processResult(results, liveTarget);
             const state: DataStreamState = {
               key: `loki-${liveTarget.refId}`,
               request: options,
               state: LoadingState.Streaming,
-              delta,
+              data,
               unsubscribe: () => this.unsubscribe(liveTarget.refId),
             };
 

+ 36 - 13
public/app/plugins/datasource/prometheus/datasource.ts

@@ -1,12 +1,12 @@
 // Libraries
 import _ from 'lodash';
 import $ from 'jquery';
-import { from, of, Observable } from 'rxjs';
-import { single, map, filter, catchError } from 'rxjs/operators';
-
 // Services & Utils
 import kbn from 'app/core/utils/kbn';
-import { dateMath } from '@grafana/data';
+import { dateMath, TimeRange, DateTime, AnnotationEvent, LoadingState } from '@grafana/data';
+import { Observable, from, of } from 'rxjs';
+import { single, filter, mergeMap, catchError } from 'rxjs/operators';
+
 import PrometheusMetricFindQuery from './metric_find_query';
 import { ResultTransformer } from './result_transformer';
 import PrometheusLanguageProvider from './language_provider';
@@ -14,7 +14,6 @@ import { BackendSrv } from 'app/core/services/backend_srv';
 import addLabelToQuery from './add_label_to_query';
 import { getQueryHints } from './query_hints';
 import { expandRecordingRules } from './language_utils';
-
 // Types
 import { PromQuery, PromOptions, PromQueryRequest, PromContext } from './types';
 import {
@@ -23,14 +22,13 @@ import {
   DataSourceInstanceSettings,
   DataQueryError,
   DataStreamObserver,
-  DataStreamState,
   DataQueryResponseData,
+  DataStreamState,
 } from '@grafana/ui';
 import { ExploreUrlState } from 'app/types/explore';
 import { safeStringifyValue } from 'app/core/utils/explore';
 import { TemplateSrv } from 'app/features/templating/template_srv';
 import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
-import { TimeRange, DateTime, LoadingState, AnnotationEvent } from '@grafana/data';
 
 export interface PromDataQueryResponse {
   data: {
@@ -183,6 +181,26 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
     activeTargets: PromQuery[],
     end: number
   ) => {
+    // Because we want to get run instant and TimeSeries Prom queries in parallel but this isn't actually streaming
+    // we need to stop/cancel each posted event with a stop stream event (see below) to the observer so that the
+    // PanelQueryState stops the stream
+    const getStopState = (state: DataStreamState): DataStreamState => ({
+      ...state,
+      state: LoadingState.Done,
+      request: { ...options, requestId: 'done' },
+    });
+
+    const startLoadingEvent: DataStreamState = {
+      key: `prometheus-loading_indicator`,
+      state: LoadingState.Loading,
+      request: options,
+      data: [],
+      unsubscribe: () => undefined,
+    };
+
+    observer(startLoadingEvent); // Starts the loading indicator
+    const lastTimeSeriesQuery = queries.filter(query => !query.instant).pop();
+
     for (let index = 0; index < queries.length; index++) {
       const query = queries[index];
       const target = activeTargets[index];
@@ -198,17 +216,23 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
         .pipe(
           single(), // unsubscribes automatically after first result
           filter((response: any) => (response.cancelled ? false : true)),
-          map((response: any) => {
-            const delta = this.processResult(response, query, target, queries.length);
+          mergeMap((response: any) => {
+            const data = this.processResult(response, query, target, queries.length);
             const state: DataStreamState = {
               key: `prometheus-${target.refId}`,
-              state: query.instant ? LoadingState.Loading : LoadingState.Done,
+              state: LoadingState.Loading,
               request: options,
-              delta,
+              data,
               unsubscribe: () => undefined,
             };
 
-            return state;
+            const states = [state, getStopState(state)];
+
+            if (target.refId === lastTimeSeriesQuery.refId && target.expr === lastTimeSeriesQuery.expr) {
+              states.push(getStopState(startLoadingEvent)); // Stops the loading indicator
+            }
+
+            return states;
           }),
           catchError(err => {
             const error = this.handleErrors(err, target);
@@ -282,7 +306,6 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
       this.runObserverQueries(options, observer, queries, activeTargets, end);
       return this.$q.when({ data: [] }) as Promise<{ data: any }>;
     }
-
     const allQueryPromise = _.map(queries, query => {
       if (query.instant) {
         return this.performInstantQuery(query, end);

+ 2 - 71
public/app/store/configureStore.ts

@@ -1,6 +1,5 @@
 import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
 import thunk from 'redux-thunk';
-import { combineEpics, createEpicMiddleware } from 'redux-observable';
 import { createLogger } from 'redux-logger';
 import sharedReducers from 'app/core/reducers';
 import alertingReducers from 'app/features/alerting/state/reducers';
@@ -15,41 +14,8 @@ import usersReducers from 'app/features/users/state/reducers';
 import userReducers from 'app/features/profile/state/reducers';
 import organizationReducers from 'app/features/org/state/reducers';
 import { setStore } from './store';
-import { limitMessageRateEpic } from 'app/features/explore/state/epics/limitMessageRateEpic';
-import { stateSaveEpic } from 'app/features/explore/state/epics/stateSaveEpic';
-import { processQueryResultsEpic } from 'app/features/explore/state/epics/processQueryResultsEpic';
-import { processQueryErrorsEpic } from 'app/features/explore/state/epics/processQueryErrorsEpic';
-import { runQueriesEpic } from 'app/features/explore/state/epics/runQueriesEpic';
-import { runQueriesBatchEpic } from 'app/features/explore/state/epics/runQueriesBatchEpic';
-import {
-  DataSourceApi,
-  DataQueryResponse,
-  DataQuery,
-  DataSourceJsonData,
-  DataQueryRequest,
-  DataStreamObserver,
-} from '@grafana/ui';
-
-import {
-  TimeZone,
-  RawTimeRange,
-  TimeRange,
-  DateTimeInput,
-  FormatInput,
-  DateTime,
-  AbsoluteTimeRange,
-  dateTimeForTimeZone,
-} from '@grafana/data';
-import { Observable } from 'rxjs';
-import { getQueryResponse } from 'app/core/utils/explore';
 import { StoreState } from 'app/types/store';
 import { toggleLogActionsMiddleware } from 'app/core/middlewares/application';
-import { timeEpic } from 'app/features/explore/state/epics/timeEpic';
-import { TimeSrv, getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
-import { UserState } from 'app/types/user';
-import { getTimeRange } from 'app/core/utils/explore';
-import { getTimeZone } from 'app/features/profile/state/selectors';
-import { getShiftedTimeRange } from 'app/core/utils/timePicker';
 
 const rootReducers = {
   ...sharedReducers,
@@ -70,40 +36,6 @@ export function addRootReducer(reducers: any) {
   Object.assign(rootReducers, ...reducers);
 }
 
-export const rootEpic: any = combineEpics(
-  limitMessageRateEpic,
-  stateSaveEpic,
-  runQueriesEpic,
-  runQueriesBatchEpic,
-  processQueryResultsEpic,
-  processQueryErrorsEpic,
-  timeEpic
-);
-
-export interface EpicDependencies {
-  getQueryResponse: (
-    datasourceInstance: DataSourceApi<DataQuery, DataSourceJsonData>,
-    options: DataQueryRequest<DataQuery>,
-    observer?: DataStreamObserver
-  ) => Observable<DataQueryResponse>;
-  getTimeSrv: () => TimeSrv;
-  getTimeRange: (timeZone: TimeZone, rawRange: RawTimeRange) => TimeRange;
-  getTimeZone: (state: UserState) => TimeZone;
-  getShiftedTimeRange: (direction: number, origRange: TimeRange, timeZone: TimeZone) => AbsoluteTimeRange;
-  dateTimeForTimeZone: (timezone?: TimeZone, input?: DateTimeInput, formatInput?: FormatInput) => DateTime;
-}
-
-const dependencies: EpicDependencies = {
-  getQueryResponse,
-  getTimeSrv,
-  getTimeRange,
-  getTimeZone,
-  getShiftedTimeRange,
-  dateTimeForTimeZone,
-};
-
-const epicMiddleware = createEpicMiddleware({ dependencies });
-
 export function configureStore() {
   const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
   const rootReducer = combineReducers(rootReducers);
@@ -114,11 +46,10 @@ export function configureStore() {
   });
   const storeEnhancers =
     process.env.NODE_ENV !== 'production'
-      ? applyMiddleware(toggleLogActionsMiddleware, thunk, epicMiddleware, logger)
-      : applyMiddleware(thunk, epicMiddleware);
+      ? applyMiddleware(toggleLogActionsMiddleware, thunk, logger)
+      : applyMiddleware(thunk);
 
   const store = createStore(rootReducer, {}, composeEnhancers(storeEnhancers));
   setStore(store);
-  epicMiddleware.run(rootEpic);
   return store;
 }

+ 3 - 0
public/app/types/explore.ts

@@ -21,6 +21,7 @@ import {
 
 import { Emitter } from 'app/core/core';
 import TableModel from 'app/core/table_model';
+import { PanelQueryState } from '../features/dashboard/state/PanelQueryState';
 
 export enum ExploreMode {
   Metrics = 'Metrics',
@@ -255,6 +256,8 @@ export interface ExploreItemState {
 
   isLive: boolean;
   urlReplaced: boolean;
+
+  queryState: PanelQueryState;
 }
 
 export interface ExploreUpdateState {

+ 0 - 149
public/test/core/redux/epicTester.ts

@@ -1,149 +0,0 @@
-import { Epic, ActionsObservable, StateObservable } from 'redux-observable';
-import { Subject } from 'rxjs';
-import {
-  DataSourceApi,
-  DataQuery,
-  DataSourceJsonData,
-  DataQueryRequest,
-  DataStreamObserver,
-  DataQueryResponse,
-  DataStreamState,
-} from '@grafana/ui';
-import { DefaultTimeZone } from '@grafana/data';
-
-import { ActionOf } from 'app/core/redux/actionCreatorFactory';
-import { StoreState } from 'app/types/store';
-import { EpicDependencies } from 'app/store/configureStore';
-import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
-import { DEFAULT_RANGE } from 'app/core/utils/explore';
-
-export const MOCKED_ABSOLUTE_RANGE = { from: 1, to: 2 };
-
-export const epicTester = (
-  epic: Epic<ActionOf<any>, ActionOf<any>, StoreState, EpicDependencies>,
-  state?: Partial<StoreState>,
-  dependencies?: Partial<EpicDependencies>
-) => {
-  const resultingActions: Array<ActionOf<any>> = [];
-  const action$ = new Subject<ActionOf<any>>();
-  const state$ = new Subject<StoreState>();
-  const actionObservable$ = new ActionsObservable(action$);
-  const stateObservable$ = new StateObservable(state$, (state as StoreState) || ({} as StoreState));
-  const queryResponse$ = new Subject<DataQueryResponse>();
-  const observer$ = new Subject<DataStreamState>();
-  const getQueryResponse = (
-    datasourceInstance: DataSourceApi<DataQuery, DataSourceJsonData>,
-    options: DataQueryRequest<DataQuery>,
-    observer?: DataStreamObserver
-  ) => {
-    if (observer) {
-      observer$.subscribe({ next: event => observer(event) });
-    }
-    return queryResponse$;
-  };
-  const init = jest.fn();
-  const getTimeSrv = (): TimeSrv => {
-    const timeSrvMock: TimeSrv = {} as TimeSrv;
-
-    return Object.assign(timeSrvMock, { init });
-  };
-
-  const getTimeRange = jest.fn().mockReturnValue(DEFAULT_RANGE);
-
-  const getShiftedTimeRange = jest.fn().mockReturnValue(MOCKED_ABSOLUTE_RANGE);
-
-  const getTimeZone = jest.fn().mockReturnValue(DefaultTimeZone);
-
-  const dateTimeForTimeZone = jest.fn().mockReturnValue(null);
-
-  const defaultDependencies: EpicDependencies = {
-    getQueryResponse,
-    getTimeSrv,
-    getTimeRange,
-    getTimeZone,
-    getShiftedTimeRange,
-    dateTimeForTimeZone,
-  };
-
-  const theDependencies: EpicDependencies = { ...defaultDependencies, ...dependencies };
-
-  epic(actionObservable$, stateObservable$, theDependencies).subscribe({
-    next: action => resultingActions.push(action),
-  });
-
-  const whenActionIsDispatched = (action: ActionOf<any>) => {
-    action$.next(action);
-
-    return instance;
-  };
-
-  const whenQueryReceivesResponse = (response: DataQueryResponse) => {
-    queryResponse$.next(response);
-
-    return instance;
-  };
-
-  const whenQueryThrowsError = (error: any) => {
-    queryResponse$.error(error);
-
-    return instance;
-  };
-
-  const whenQueryObserverReceivesEvent = (event: DataStreamState) => {
-    observer$.next(event);
-
-    return instance;
-  };
-
-  const thenResultingActionsEqual = (...actions: Array<ActionOf<any>>) => {
-    expect(actions).toEqual(resultingActions);
-
-    return instance;
-  };
-
-  const thenNoActionsWhereDispatched = () => {
-    expect(resultingActions).toEqual([]);
-
-    return instance;
-  };
-
-  const getDependencyMock = (dependency: string, method?: string) => {
-    // @ts-ignore
-    const dep = theDependencies[dependency];
-    let mock = null;
-    if (dep instanceof Function) {
-      mock = method ? dep()[method] : dep();
-    } else {
-      mock = method ? dep[method] : dep;
-    }
-
-    return mock;
-  };
-
-  const thenDependencyWasCalledTimes = (times: number, dependency: string, method?: string) => {
-    const mock = getDependencyMock(dependency, method);
-    expect(mock).toBeCalledTimes(times);
-
-    return instance;
-  };
-
-  const thenDependencyWasCalledWith = (args: any[], dependency: string, method?: string) => {
-    const mock = getDependencyMock(dependency, method);
-    expect(mock).toBeCalledWith(...args);
-
-    return instance;
-  };
-
-  const instance = {
-    whenActionIsDispatched,
-    whenQueryReceivesResponse,
-    whenQueryThrowsError,
-    whenQueryObserverReceivesEvent,
-    thenResultingActionsEqual,
-    thenNoActionsWhereDispatched,
-    thenDependencyWasCalledTimes,
-    thenDependencyWasCalledWith,
-  };
-
-  return instance;
-};