浏览代码

Explore: Parses and updates TimeSrv in one place in Explore (#17677)

* Wip: Adds timeEpic

* Refactor: Introduces absoluteRange in Explore state

* Refactor: Removes changeTime action

* Tests: Adds tests for timeEpic

* Refactor: Spells AbsoluteRange correctly
Hugo Häggmark 6 年之前
父节点
当前提交
2c5400c61e

+ 3 - 0
public/app/features/dashboard/services/TimeSrv.ts

@@ -112,6 +112,9 @@ export class TimeSrv {
 
   private routeUpdated() {
     const params = this.$location.search();
+    if (params.left) {
+      return; // explore handles this;
+    }
     const urlRange = this.timeRangeForUrl();
     // check if url has time range
     if (params.from && params.to) {

+ 7 - 6
public/app/features/explore/Explore.tsx

@@ -21,13 +21,13 @@ import TimePicker from './TimePicker';
 // Actions
 import {
   changeSize,
-  changeTime,
   initializeExplore,
   modifyQueries,
   scanStart,
   setQueries,
   refreshExplore,
   reconnectDatasource,
+  updateTimeRange,
 } from './state/actions';
 
 // Types
@@ -60,7 +60,6 @@ import { scanStopAction } from './state/actionTypes';
 interface ExploreProps {
   StartPage?: ComponentClass<ExploreStartPageProps>;
   changeSize: typeof changeSize;
-  changeTime: typeof changeTime;
   datasourceError: string;
   datasourceInstance: DataSourceApi;
   datasourceLoading: boolean | null;
@@ -88,6 +87,7 @@ interface ExploreProps {
   queryErrors: DataQueryError[];
   mode: ExploreMode;
   isLive: boolean;
+  updateTimeRange: typeof updateTimeRange;
 }
 
 /**
@@ -158,11 +158,12 @@ export class Explore extends React.PureComponent<ExploreProps> {
     this.el = el;
   };
 
-  onChangeTime = (range: RawTimeRange, changedByScanner?: boolean) => {
-    if (this.props.scanning && !changedByScanner) {
+  onChangeTime = (rawRange: RawTimeRange, changedByScanner?: boolean) => {
+    const { updateTimeRange, exploreId, scanning } = this.props;
+    if (scanning && !changedByScanner) {
       this.onStopScanning();
     }
-    this.props.changeTime(this.props.exploreId, range);
+    updateTimeRange({ exploreId, rawRange });
   };
 
   // Use this in help pages to set page to a single query
@@ -348,7 +349,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
 
 const mapDispatchToProps = {
   changeSize,
-  changeTime,
   initializeExplore,
   modifyQueries,
   reconnectDatasource,
@@ -356,6 +356,7 @@ const mapDispatchToProps = {
   scanStart,
   scanStopAction,
   setQueries,
+  updateTimeRange,
 };
 
 export default hot(module)(

+ 30 - 18
public/app/features/explore/GraphContainer.tsx

@@ -1,28 +1,27 @@
 import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
-import { TimeRange, TimeZone, AbsoluteTimeRange, LoadingState } from '@grafana/ui';
+import { TimeZone, AbsoluteTimeRange, LoadingState } from '@grafana/ui';
 
 import { ExploreId, ExploreItemState } from 'app/types/explore';
 import { StoreState } from 'app/types';
 
-import { toggleGraph, changeTime } from './state/actions';
+import { toggleGraph, updateTimeRange } from './state/actions';
 import Graph from './Graph';
 import Panel from './Panel';
 import { getTimeZone } from '../profile/state/selectors';
-import { toUtc, dateTime } from '@grafana/ui/src/utils/moment_wrapper';
 
 interface GraphContainerProps {
   exploreId: ExploreId;
   graphResult?: any[];
   loading: boolean;
-  range: TimeRange;
+  absoluteRange: AbsoluteTimeRange;
   timeZone: TimeZone;
   showingGraph: boolean;
   showingTable: boolean;
   split: boolean;
   toggleGraph: typeof toggleGraph;
-  changeTime: typeof changeTime;
+  updateTimeRange: typeof updateTimeRange;
   width: number;
 }
 
@@ -31,20 +30,25 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
     this.props.toggleGraph(this.props.exploreId, this.props.showingGraph);
   };
 
-  onChangeTime = (absRange: AbsoluteTimeRange) => {
-    const { exploreId, timeZone, changeTime } = this.props;
-    const range = {
-      from: timeZone === 'utc' ? toUtc(absRange.from) : dateTime(absRange.from),
-      to: timeZone === 'utc' ? toUtc(absRange.to) : dateTime(absRange.to),
-    };
+  onChangeTime = (absoluteRange: AbsoluteTimeRange) => {
+    const { exploreId, updateTimeRange } = this.props;
 
-    changeTime(exploreId, range);
+    updateTimeRange({ exploreId, absoluteRange });
   };
 
   render() {
-    const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width, timeZone } = this.props;
+    const {
+      exploreId,
+      graphResult,
+      loading,
+      showingGraph,
+      showingTable,
+      absoluteRange,
+      split,
+      width,
+      timeZone,
+    } = this.props;
     const graphHeight = showingGraph && showingTable ? 200 : 400;
-    const timeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
 
     return (
       <Panel label="Graph" collapsible isOpen={showingGraph} loading={loading} onToggle={this.onClickGraphButton}>
@@ -54,7 +58,7 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
             height={graphHeight}
             id={`explore-graph-${exploreId}`}
             onChangeTime={this.onChangeTime}
-            range={timeRange}
+            range={absoluteRange}
             timeZone={timeZone}
             split={split}
             width={width}
@@ -69,14 +73,22 @@ function mapStateToProps(state: StoreState, { exploreId }) {
   const explore = state.explore;
   const { split } = explore;
   const item: ExploreItemState = explore[exploreId];
-  const { graphResult, loadingState, range, showingGraph, showingTable } = item;
+  const { graphResult, loadingState, showingGraph, showingTable, absoluteRange } = item;
   const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming;
-  return { graphResult, loading, range, showingGraph, showingTable, split, timeZone: getTimeZone(state.user) };
+  return {
+    graphResult,
+    loading,
+    showingGraph,
+    showingTable,
+    split,
+    timeZone: getTimeZone(state.user),
+    absoluteRange,
+  };
 }
 
 const mapDispatchToProps = {
   toggleGraph,
-  changeTime,
+  updateTimeRange,
 };
 
 export default hot(module)(

+ 3 - 8
public/app/features/explore/Logs.tsx

@@ -7,7 +7,6 @@ import {
   Switch,
   LogLevel,
   TimeZone,
-  TimeRange,
   AbsoluteTimeRange,
   LogsMetaKind,
   LogsModel,
@@ -58,7 +57,7 @@ interface Props {
   exploreId: string;
   highlighterExpressions: string[];
   loading: boolean;
-  range: TimeRange;
+  absoluteRange: AbsoluteTimeRange;
   timeZone: TimeZone;
   scanning?: boolean;
   scanRange?: RawTimeRange;
@@ -167,7 +166,7 @@ export default class Logs extends PureComponent<Props, State> {
       highlighterExpressions,
       loading = false,
       onClickLabel,
-      range,
+      absoluteRange,
       timeZone,
       scanning,
       scanRange,
@@ -206,10 +205,6 @@ export default class Logs extends PureComponent<Props, State> {
     const timeSeries = data.series
       ? data.series.map(series => new TimeSeries(series))
       : [new TimeSeries({ datapoints: [] })];
-    const absRange = {
-      from: range.from.valueOf(),
-      to: range.to.valueOf(),
-    };
 
     return (
       <div className="logs-panel">
@@ -218,7 +213,7 @@ export default class Logs extends PureComponent<Props, State> {
             data={timeSeries}
             height={100}
             width={width}
-            range={absRange}
+            range={absoluteRange}
             timeZone={timeZone}
             id={`explore-logs-graph-${exploreId}`}
             onChangeTime={this.props.onChangeTime}

+ 12 - 19
public/app/features/explore/LogsContainer.tsx

@@ -3,12 +3,9 @@ import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
 import {
   RawTimeRange,
-  TimeRange,
   LogLevel,
   TimeZone,
   AbsoluteTimeRange,
-  toUtc,
-  dateTime,
   DataSourceApi,
   LogsModel,
   LogRowModel,
@@ -19,7 +16,7 @@ import {
 import { ExploreId, ExploreItemState } from 'app/types/explore';
 import { StoreState } from 'app/types';
 
-import { changeDedupStrategy, changeTime } from './state/actions';
+import { changeDedupStrategy, updateTimeRange } from './state/actions';
 import Logs from './Logs';
 import Panel from './Panel';
 import { toggleLogLevelAction, changeRefreshIntervalAction } from 'app/features/explore/state/actionTypes';
@@ -39,7 +36,6 @@ interface LogsContainerProps {
   onClickLabel: (key: string, value: string) => void;
   onStartScanning: () => void;
   onStopScanning: () => void;
-  range: TimeRange;
   timeZone: TimeZone;
   scanning?: boolean;
   scanRange?: RawTimeRange;
@@ -48,20 +44,17 @@ interface LogsContainerProps {
   dedupStrategy: LogsDedupStrategy;
   hiddenLogLevels: Set<LogLevel>;
   width: number;
-  changeTime: typeof changeTime;
   isLive: boolean;
   stopLive: typeof changeRefreshIntervalAction;
+  updateTimeRange: typeof updateTimeRange;
+  absoluteRange: AbsoluteTimeRange;
 }
 
 export class LogsContainer extends Component<LogsContainerProps> {
-  onChangeTime = (absRange: AbsoluteTimeRange) => {
-    const { exploreId, timeZone, changeTime } = this.props;
-    const range = {
-      from: timeZone === 'utc' ? toUtc(absRange.from) : dateTime(absRange.from),
-      to: timeZone === 'utc' ? toUtc(absRange.to) : dateTime(absRange.to),
-    };
-
-    changeTime(exploreId, range);
+  onChangeTime = (absoluteRange: AbsoluteTimeRange) => {
+    const { exploreId, updateTimeRange } = this.props;
+
+    updateTimeRange({ exploreId, absoluteRange });
   };
 
   onStopLive = () => {
@@ -111,7 +104,7 @@ export class LogsContainer extends Component<LogsContainerProps> {
       onClickLabel,
       onStartScanning,
       onStopScanning,
-      range,
+      absoluteRange,
       timeZone,
       scanning,
       scanRange,
@@ -143,7 +136,7 @@ export class LogsContainer extends Component<LogsContainerProps> {
           onStopScanning={onStopScanning}
           onDedupStrategyChange={this.handleDedupStrategyChange}
           onToggleLogLevel={this.hangleToggleLogLevel}
-          range={range}
+          absoluteRange={absoluteRange}
           timeZone={timeZone}
           scanning={scanning}
           scanRange={scanRange}
@@ -165,9 +158,9 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     loadingState,
     scanning,
     scanRange,
-    range,
     datasourceInstance,
     isLive,
+    absoluteRange,
   } = item;
   const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming;
   const { dedupStrategy } = exploreItemUIStateSelector(item);
@@ -181,21 +174,21 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     logsResult,
     scanning,
     scanRange,
-    range,
     timeZone,
     dedupStrategy,
     hiddenLogLevels,
     dedupedResult,
     datasourceInstance,
     isLive,
+    absoluteRange,
   };
 }
 
 const mapDispatchToProps = {
   changeDedupStrategy,
   toggleLogLevelAction,
-  changeTime,
   stopLive: changeRefreshIntervalAction,
+  updateTimeRange,
 };
 
 export default hot(module)(

+ 10 - 11
public/app/features/explore/state/actionTypes.ts

@@ -14,6 +14,7 @@ import {
   TimeSeries,
   DataQueryResponseData,
   LoadingState,
+  AbsoluteTimeRange,
 } from '@grafana/ui/src/types';
 import {
   ExploreId,
@@ -73,11 +74,6 @@ export interface ChangeSizePayload {
   height: number;
 }
 
-export interface ChangeTimePayload {
-  exploreId: ExploreId;
-  range: TimeRange;
-}
-
 export interface ChangeRefreshIntervalPayload {
   exploreId: ExploreId;
   refreshInterval: string;
@@ -233,7 +229,6 @@ export interface LoadExploreDataSourcesPayload {
 
 export interface RunQueriesPayload {
   exploreId: ExploreId;
-  range: TimeRange;
 }
 
 export interface ResetQueryErrorPayload {
@@ -274,6 +269,13 @@ export interface LimitMessageRatePayload {
 export interface ChangeRangePayload {
   exploreId: ExploreId;
   range: TimeRange;
+  absoluteRange: AbsoluteTimeRange;
+}
+
+export interface UpdateTimeRangePayload {
+  exploreId: ExploreId;
+  rawRange?: RawTimeRange;
+  absoluteRange?: AbsoluteTimeRange;
 }
 
 /**
@@ -303,11 +305,6 @@ export const changeQueryAction = actionCreatorFactory<ChangeQueryPayload>('explo
  */
 export const changeSizeAction = actionCreatorFactory<ChangeSizePayload>('explore/CHANGE_SIZE').create();
 
-/**
- * Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
- */
-export const changeTimeAction = actionCreatorFactory<ChangeTimePayload>('explore/CHANGE_TIME').create();
-
 /**
  * Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
  */
@@ -490,6 +487,8 @@ export const limitMessageRatePayloadAction = actionCreatorFactory<LimitMessageRa
 
 export const changeRangeAction = actionCreatorFactory<ChangeRangePayload>('explore/CHANGE_RANGE').create();
 
+export const updateTimeRangeAction = actionCreatorFactory<UpdateTimeRangePayload>('explore/UPDATE_TIMERANGE').create();
+
 export type HigherOrderAction =
   | ActionOf<SplitCloseActionPayload>
   | SplitOpenAction

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

@@ -4,7 +4,6 @@ import { thunkTester } from 'test/core/thunk/thunkTester';
 import {
   initializeExploreAction,
   InitializeExplorePayload,
-  changeTimeAction,
   updateUIStateAction,
   setQueriesAction,
   testDataSourcePendingAction,
@@ -12,6 +11,7 @@ import {
   testDataSourceFailureAction,
   loadDatasourcePendingAction,
   loadDatasourceReadyAction,
+  updateTimeRangeAction,
 } from './actionTypes';
 import { Emitter } from 'app/core/core';
 import { ActionOf } from 'app/core/redux/actionCreatorFactory';
@@ -118,15 +118,15 @@ describe('refreshExplore', () => {
     });
 
     describe('and update range is set', () => {
-      it('then it should dispatch changeTimeAction', async () => {
+      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(changeTimeAction.type);
-        expect(dispatchedActions[0].payload).toEqual({ exploreId, range });
+        expect(dispatchedActions[0].type).toEqual(updateTimeRangeAction.type);
+        expect(dispatchedActions[0].payload).toEqual({ exploreId, rawRange: range.raw });
       });
     });
 

+ 14 - 18
public/app/features/explore/state/actions.ts

@@ -24,6 +24,7 @@ import {
   DataSourceSelectItem,
   QueryFixAction,
   LogsDedupStrategy,
+  AbsoluteTimeRange,
 } from '@grafana/ui';
 import { ExploreId, RangeScanner, ExploreUIState, QueryTransaction, ExploreMode } from 'app/types/explore';
 import {
@@ -33,7 +34,6 @@ import {
   ChangeRefreshIntervalPayload,
   changeSizeAction,
   ChangeSizePayload,
-  changeTimeAction,
   clearQueriesAction,
   initializeExploreAction,
   loadDatasourceMissingAction,
@@ -61,6 +61,7 @@ import {
   scanRangeAction,
   runQueriesAction,
   stateSaveAction,
+  updateTimeRangeAction,
 } from './actionTypes';
 import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
 import { getTimeZone } from 'app/features/profile/state/selectors';
@@ -164,17 +165,16 @@ export function changeSize(
   return changeSizeAction({ exploreId, height, width });
 }
 
-/**
- * Change the time range of Explore. Usually called from the Time picker or a graph interaction.
- */
-export function changeTime(exploreId: ExploreId, rawRange: RawTimeRange): ThunkResult<void> {
-  return (dispatch, getState) => {
-    const timeZone = getTimeZone(getState().user);
-    const range = getTimeRange(timeZone, rawRange);
-    dispatch(changeTimeAction({ exploreId, range }));
-    dispatch(runQueries(exploreId));
+export const updateTimeRange = (options: {
+  exploreId: ExploreId;
+  rawRange?: RawTimeRange;
+  absoluteRange?: AbsoluteTimeRange;
+}): ThunkResult<void> => {
+  return dispatch => {
+    dispatch(updateTimeRangeAction({ ...options }));
+    dispatch(runQueries(options.exploreId));
   };
-}
+};
 
 /**
  * Change the refresh interval of Explore. Called from the Refresh picker.
@@ -402,12 +402,8 @@ export function modifyQueries(
  */
 export function runQueries(exploreId: ExploreId): ThunkResult<void> {
   return (dispatch, getState) => {
-    const { range } = getState().explore[exploreId];
-
-    const timeZone = getTimeZone(getState().user);
-    const updatedRange = getTimeRange(timeZone, range.raw);
-
-    dispatch(runQueriesAction({ exploreId, range: updatedRange }));
+    dispatch(updateTimeRangeAction({ exploreId }));
+    dispatch(runQueriesAction({ exploreId }));
   };
 }
 
@@ -548,7 +544,7 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
     }
 
     if (update.range) {
-      dispatch(changeTimeAction({ exploreId, range }));
+      dispatch(updateTimeRangeAction({ exploreId, rawRange: range.raw }));
     }
 
     // need to refresh ui state

+ 18 - 2
public/app/features/explore/state/epics/runQueriesBatchEpic.ts

@@ -3,7 +3,14 @@ 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, LoadingState, DataQueryResponse, SeriesData, DataQueryResponseData } from '@grafana/ui';
+import {
+  DataStreamState,
+  LoadingState,
+  DataQueryResponse,
+  SeriesData,
+  DataQueryResponseData,
+  AbsoluteTimeRange,
+} from '@grafana/ui';
 import * as dateMath from '@grafana/ui/src/utils/datemath';
 
 import { ActionOf } from 'app/core/redux/actionCreatorFactory';
@@ -115,15 +122,24 @@ export const runQueriesBatchEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState>
             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 }));
+                outerObservable.next(changeRangeAction({ exploreId, range: newRange, absoluteRange }));
               }
+
               outerObservable.next(
                 limitMessageRatePayloadAction({
                   exploreId,

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

@@ -13,7 +13,7 @@ describe('runQueriesEpic', () => {
             const { exploreId, state, datasourceInterval, containerWidth } = mockExploreState({ queries });
 
             epicTester(runQueriesEpic, state)
-              .whenActionIsDispatched(runQueriesAction({ exploreId, range: null }))
+              .whenActionIsDispatched(runQueriesAction({ exploreId }))
               .thenResultingActionsEqual(
                 runQueriesBatchAction({
                   exploreId,
@@ -33,7 +33,7 @@ describe('runQueriesEpic', () => {
             });
 
             epicTester(runQueriesEpic, state)
-              .whenActionIsDispatched(runQueriesAction({ exploreId, range: null }))
+              .whenActionIsDispatched(runQueriesAction({ exploreId }))
               .thenResultingActionsEqual(
                 runQueriesBatchAction({
                   exploreId,
@@ -50,7 +50,7 @@ describe('runQueriesEpic', () => {
           const { exploreId, state } = mockExploreState({ queries });
 
           epicTester(runQueriesEpic, state)
-            .whenActionIsDispatched(runQueriesAction({ exploreId, range: null }))
+            .whenActionIsDispatched(runQueriesAction({ exploreId }))
             .thenResultingActionsEqual(clearQueriesAction({ exploreId }), stateSaveAction());
         });
       });
@@ -63,7 +63,7 @@ describe('runQueriesEpic', () => {
         });
 
         epicTester(runQueriesEpic, state)
-          .whenActionIsDispatched(runQueriesAction({ exploreId, range: null }))
+          .whenActionIsDispatched(runQueriesAction({ exploreId }))
           .thenNoActionsWhereDispatched();
       });
     });

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

@@ -0,0 +1,105 @@
+import { dateTime, DefaultTimeZone } from '@grafana/ui';
+
+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, 'dateTime')
+            .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,
+              })
+            );
+        });
+      });
+    });
+  });
+});

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

@@ -0,0 +1,46 @@
+import { Epic } from 'redux-observable';
+import { map } from 'rxjs/operators';
+import { AbsoluteTimeRange, RawTimeRange } from '@grafana/ui';
+
+import { ActionOf } from 'app/core/redux/actionCreatorFactory';
+import { StoreState } from 'app/types/store';
+import { updateTimeRangeAction, UpdateTimeRangePayload, changeRangeAction } from '../actionTypes';
+
+export const timeEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState> = (
+  action$,
+  state$,
+  { getTimeSrv, getTimeRange, getTimeZone, toUtc, dateTime }
+) => {
+  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: timeZone.isUtc ? toUtc(absRange.from) : dateTime(absRange.from),
+          to: timeZone.isUtc ? toUtc(absRange.to) : dateTime(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.raw,
+        timeRangeUpdated: () => undefined,
+      });
+
+      return changeRangeAction({ exploreId, range, absoluteRange });
+    })
+  );
+};

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

@@ -4,7 +4,6 @@ import {
   exploreReducer,
   makeInitialUpdateState,
   initialExploreState,
-  DEFAULT_RANGE,
 } from './reducers';
 import {
   ExploreId,
@@ -32,7 +31,7 @@ import { ActionOf } from 'app/core/redux/actionCreatorFactory';
 import { updateLocation } from 'app/core/actions/location';
 import { serializeStateToUrlParam } from 'app/core/utils/explore';
 import TableModel from 'app/core/table_model';
-import { DataSourceApi, DataQuery, LogsModel, LogsDedupStrategy, LoadingState, dateTime } from '@grafana/ui';
+import { DataSourceApi, DataQuery, LogsModel, LogsDedupStrategy, LoadingState } from '@grafana/ui';
 
 describe('Explore item reducer', () => {
   describe('scanning', () => {
@@ -193,16 +192,12 @@ describe('Explore item reducer', () => {
             intervalMs: 1000,
           },
           showingStartPage: false,
-          range: {
-            from: dateTime(),
-            to: dateTime(),
-            raw: DEFAULT_RANGE,
-          },
+          range: null,
         };
 
         reducerTester()
           .givenReducer(itemReducer, initalState)
-          .whenActionIsDispatched(runQueriesAction({ exploreId: ExploreId.left, range: expectedState.range }))
+          .whenActionIsDispatched(runQueriesAction({ exploreId: ExploreId.left }))
           .thenStateShouldEqual(expectedState);
       });
     });

+ 9 - 10
public/app/features/explore/state/reducers.ts

@@ -36,7 +36,6 @@ import {
   addQueryRowAction,
   changeQueryAction,
   changeSizeAction,
-  changeTimeAction,
   changeRefreshIntervalAction,
   clearQueriesAction,
   highlightLogsExpressionAction,
@@ -95,6 +94,10 @@ export const makeExploreItemState = (): ExploreItemState => ({
     to: null,
     raw: DEFAULT_RANGE,
   },
+  absoluteRange: {
+    from: null,
+    to: null,
+  },
   scanning: false,
   scanRange: null,
   showingGraph: true,
@@ -174,12 +177,6 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       return { ...state, mode };
     },
   })
-  .addMapper({
-    filter: changeTimeAction,
-    mapper: (state, action): ExploreItemState => {
-      return { ...state, range: action.payload.range };
-    },
-  })
   .addMapper({
     filter: changeRefreshIntervalAction,
     mapper: (state, action): ExploreItemState => {
@@ -520,8 +517,8 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
   })
   .addMapper({
     filter: runQueriesAction,
-    mapper: (state, action): ExploreItemState => {
-      const { range } = action.payload;
+    mapper: (state): ExploreItemState => {
+      const { range } = state;
       const { datasourceInstance, containerWidth } = state;
       let interval = '1s';
       if (datasourceInstance && datasourceInstance.interval) {
@@ -575,9 +572,11 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
   .addMapper({
     filter: changeRangeAction,
     mapper: (state, action): ExploreItemState => {
+      const { range, absoluteRange } = action.payload;
       return {
         ...state,
-        range: action.payload.range,
+        range,
+        absoluteRange,
       };
     },
   })

+ 25 - 1
public/app/store/configureStore.ts

@@ -28,11 +28,24 @@ import {
   DataSourceJsonData,
   DataQueryRequest,
   DataStreamObserver,
+  TimeZone,
+  RawTimeRange,
+  TimeRange,
+  DateTimeInput,
+  FormatInput,
+  DateTime,
+  toUtc,
+  dateTime,
 } from '@grafana/ui';
 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';
 
 const rootReducers = {
   ...sharedReducers,
@@ -59,7 +72,8 @@ export const rootEpic: any = combineEpics(
   runQueriesEpic,
   runQueriesBatchEpic,
   processQueryResultsEpic,
-  processQueryErrorsEpic
+  processQueryErrorsEpic,
+  timeEpic
 );
 
 export interface EpicDependencies {
@@ -68,10 +82,20 @@ export interface EpicDependencies {
     options: DataQueryRequest<DataQuery>,
     observer?: DataStreamObserver
   ) => Observable<DataQueryResponse>;
+  getTimeSrv: () => TimeSrv;
+  getTimeRange: (timeZone: TimeZone, rawRange: RawTimeRange) => TimeRange;
+  getTimeZone: (state: UserState) => TimeZone;
+  toUtc: (input?: DateTimeInput, formatInput?: FormatInput) => DateTime;
+  dateTime: (input?: DateTimeInput, formatInput?: FormatInput) => DateTime;
 }
 
 const dependencies: EpicDependencies = {
   getQueryResponse,
+  getTimeSrv,
+  getTimeRange,
+  getTimeZone,
+  toUtc,
+  dateTime,
 };
 
 const epicMiddleware = createEpicMiddleware({ dependencies });

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

@@ -12,6 +12,7 @@ import {
   LogsModel,
   LogsDedupStrategy,
   LoadingState,
+  AbsoluteTimeRange,
 } from '@grafana/ui';
 
 import { Emitter } from 'app/core/core';
@@ -189,6 +190,8 @@ export interface ExploreItemState {
    * Time range for this Explore. Managed by the time picker and used by all query runs.
    */
   range: TimeRange;
+
+  absoluteRange: AbsoluteTimeRange;
   /**
    * Scanner function that calculates a new range, triggers a query run, and returns the new range.
    */

+ 58 - 3
public/test/core/redux/epicTester.ts

@@ -8,15 +8,19 @@ import {
   DataStreamObserver,
   DataQueryResponse,
   DataStreamState,
+  DefaultTimeZone,
 } from '@grafana/ui';
 
 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 epicTester = (
   epic: Epic<ActionOf<any>, ActionOf<any>, StoreState, EpicDependencies>,
-  state?: Partial<StoreState>
+  state?: Partial<StoreState>,
+  dependencies?: Partial<EpicDependencies>
 ) => {
   const resultingActions: Array<ActionOf<any>> = [];
   const action$ = new Subject<ActionOf<any>>();
@@ -35,12 +39,35 @@ export const epicTester = (
     }
     return queryResponse$;
   };
+  const init = jest.fn();
+  const getTimeSrv = (): TimeSrv => {
+    const timeSrvMock: TimeSrv = {} as TimeSrv;
 
-  const dependencies: EpicDependencies = {
+    return Object.assign(timeSrvMock, { init });
+  };
+
+  const getTimeRange = jest.fn().mockReturnValue(DEFAULT_RANGE);
+
+  const getTimeZone = jest.fn().mockReturnValue(DefaultTimeZone);
+
+  const toUtc = jest.fn().mockReturnValue(null);
+
+  const dateTime = jest.fn().mockReturnValue(null);
+
+  const defaultDependencies: EpicDependencies = {
     getQueryResponse,
+    getTimeSrv,
+    getTimeRange,
+    getTimeZone,
+    toUtc,
+    dateTime,
   };
 
-  epic(actionObservable$, stateObservable$, dependencies).subscribe({ next: action => resultingActions.push(action) });
+  const theDependencies: EpicDependencies = { ...defaultDependencies, ...dependencies };
+
+  epic(actionObservable$, stateObservable$, theDependencies).subscribe({
+    next: action => resultingActions.push(action),
+  });
 
   const whenActionIsDispatched = (action: ActionOf<any>) => {
     action$.next(action);
@@ -78,6 +105,32 @@ export const epicTester = (
     return instance;
   };
 
+  const getDependencyMock = (dependency: string, method?: string) => {
+    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,
@@ -85,6 +138,8 @@ export const epicTester = (
     whenQueryObserverReceivesEvent,
     thenResultingActionsEqual,
     thenNoActionsWhereDispatched,
+    thenDependencyWasCalledTimes,
+    thenDependencyWasCalledWith,
   };
 
   return instance;

+ 12 - 0
public/test/mocks/mockExploreState.ts

@@ -3,6 +3,7 @@ import { DataSourceApi } from '@grafana/ui/src/types/datasource';
 import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore';
 import { makeExploreItemState } from 'app/features/explore/state/reducers';
 import { StoreState } from 'app/types';
+import { TimeRange, dateTime } from '@grafana/ui';
 
 export const mockExploreState = (options: any = {}) => {
   const isLive = options.isLive || false;
@@ -31,6 +32,14 @@ export const mockExploreState = (options: any = {}) => {
     },
     interval: datasourceInterval,
   };
+  const range: TimeRange = options.range || {
+    from: dateTime('2019-01-01 10:00:00.000Z'),
+    to: dateTime('2019-01-01 16:00:00.000Z'),
+    raw: {
+      from: 'now-6h',
+      to: 'now',
+    },
+  };
   const urlReplaced = options.urlReplaced || false;
   const left: ExploreItemState = options.left || {
     ...makeExploreItemState(),
@@ -45,6 +54,7 @@ export const mockExploreState = (options: any = {}) => {
     scanner,
     scanning,
     urlReplaced,
+    range,
   };
   const right: ExploreItemState = options.right || {
     ...makeExploreItemState(),
@@ -59,6 +69,7 @@ export const mockExploreState = (options: any = {}) => {
     scanner,
     scanning,
     urlReplaced,
+    range,
   };
   const split: boolean = options.split || false;
   const explore: ExploreState = {
@@ -82,5 +93,6 @@ export const mockExploreState = (options: any = {}) => {
     refreshInterval,
     state,
     scanner,
+    range,
   };
 };