Browse Source

Explore: Support user timezone (#16469)

Explore now uses the timezone of the user to decide if local browser 
time or UTC should be used. 
- Now uses TimeRange instead of RawTimeRange in explore item
state tree and only parsing actual time in a few action
handlers.
- Time picker should now properly handle moving back/forward and
apply time range when both utc and non utc time zone.
- URL range representation is changed from YYYY-MM-DD HH:mm:ss
to epoch ms.
- Now uses AbsoluteTimeRange in graph component instead of moment.
- Makes a copy of the time range passed to timeSrv to make sure immutability
of explore time range when for example elasticsearch test datasources uses
timeSrv and sets a time range of last 1 min.
- Various refactorings and cleanup.

Closes #12812
Marcus Efraimsson 6 years ago
parent
commit
02cb7ff436

+ 19 - 0
packages/grafana-ui/src/types/time.ts

@@ -11,11 +11,30 @@ export interface TimeRange {
   raw: RawTimeRange;
 }
 
+export interface AbsoluteTimeRange {
+  from: number;
+  to: number;
+}
+
 export interface IntervalValues {
   interval: string; // 10s,5m
   intervalMs: number;
 }
 
+export interface TimeZone {
+  raw: string;
+  isUtc: boolean;
+}
+
+export const parseTimeZone = (raw: string): TimeZone => {
+  return {
+    raw,
+    isUtc: raw === 'utc',
+  };
+};
+
+export const DefaultTimeZone = parseTimeZone('browser');
+
 export interface TimeOption {
   from: string;
   to: string;

+ 55 - 17
public/app/core/utils/explore.ts

@@ -1,18 +1,17 @@
 // Libraries
 import _ from 'lodash';
+import moment, { Moment } from 'moment';
 
 // Services & Utils
 import * as dateMath from 'app/core/utils/datemath';
 import { renderUrl } from 'app/core/utils/url';
 import kbn from 'app/core/utils/kbn';
 import store from 'app/core/store';
-import { parse as parseDate } from 'app/core/utils/datemath';
-import { colors } from '@grafana/ui';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
 import { getNextRefIdChar } from './query';
 
 // Types
-import { RawTimeRange, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui';
+import { colors, TimeRange, RawTimeRange, TimeZone, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui';
 import TimeSeries from 'app/core/time_series2';
 import {
   ExploreUrlState,
@@ -104,7 +103,7 @@ export function buildQueryTransaction(
   rowIndex: number,
   resultType: ResultType,
   queryOptions: QueryOptions,
-  range: RawTimeRange,
+  range: TimeRange,
   queryIntervals: QueryIntervals,
   scanning: boolean
 ): QueryTransaction {
@@ -131,12 +130,8 @@ export function buildQueryTransaction(
     intervalMs,
     panelId,
     targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
-    range: {
-      from: dateMath.parse(range.from, false),
-      to: dateMath.parse(range.to, true),
-      raw: range,
-    },
-    rangeRaw: range,
+    range,
+    rangeRaw: range.raw,
     scopedVars: {
       __interval: { text: interval, value: interval },
       __interval_ms: { text: intervalMs, value: intervalMs },
@@ -315,17 +310,12 @@ export function calculateResultsFromQueryTransactions(
   };
 }
 
-export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: number): IntervalValues {
+export function getIntervals(range: TimeRange, lowLimit: string, resolution: number): IntervalValues {
   if (!resolution) {
     return { interval: '1s', intervalMs: 1000 };
   }
 
-  const absoluteRange: RawTimeRange = {
-    from: parseDate(range.from, false),
-    to: parseDate(range.to, true),
-  };
-
-  return kbn.calculateInterval(absoluteRange, resolution, lowLimit);
+  return kbn.calculateInterval(range, resolution, lowLimit);
 }
 
 export const makeTimeSeriesList: ResultGetter = (dataList, transaction, allTransactions) => {
@@ -395,3 +385,51 @@ export const getQueryKeys = (queries: DataQuery[], datasourceInstance: DataSourc
 
   return queryKeys;
 };
+
+export const getTimeRange = (timeZone: TimeZone, rawRange: RawTimeRange): TimeRange => {
+  return {
+    from: dateMath.parse(rawRange.from, false, timeZone.raw as any),
+    to: dateMath.parse(rawRange.to, true, timeZone.raw as any),
+    raw: rawRange,
+  };
+};
+
+const parseRawTime = (value): Moment | string => {
+  if (value === null) {
+    return null;
+  }
+
+  if (value.indexOf('now') !== -1) {
+    return value;
+  }
+  if (value.length === 8) {
+    return moment.utc(value, 'YYYYMMDD');
+  }
+  if (value.length === 15) {
+    return moment.utc(value, 'YYYYMMDDTHHmmss');
+  }
+  // Backward compatibility
+  if (value.length === 19) {
+    return moment.utc(value, 'YYYY-MM-DD HH:mm:ss');
+  }
+
+  if (!isNaN(value)) {
+    const epoch = parseInt(value, 10);
+    return moment.utc(epoch);
+  }
+
+  return null;
+};
+
+export const getTimeRangeFromUrl = (range: RawTimeRange, timeZone: TimeZone): TimeRange => {
+  const raw = {
+    from: parseRawTime(range.from),
+    to: parseRawTime(range.to),
+  };
+
+  return {
+    from: dateMath.parse(raw.from, false, timeZone.raw as any),
+    to: dateMath.parse(raw.to, true, timeZone.raw as any),
+    raw,
+  };
+};

+ 38 - 16
public/app/features/explore/Explore.tsx

@@ -16,7 +16,7 @@ import GraphContainer from './GraphContainer';
 import LogsContainer from './LogsContainer';
 import QueryRows from './QueryRows';
 import TableContainer from './TableContainer';
-import TimePicker, { parseTime } from './TimePicker';
+import TimePicker from './TimePicker';
 
 // Actions
 import {
@@ -31,15 +31,29 @@ import {
 } from './state/actions';
 
 // Types
-import { RawTimeRange, TimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui';
-import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId, ExploreUpdateState } from 'app/types/explore';
+import { RawTimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui';
+import {
+  ExploreItemState,
+  ExploreUrlState,
+  RangeScanner,
+  ExploreId,
+  ExploreUpdateState,
+  ExploreUIState,
+} from 'app/types/explore';
 import { StoreState } from 'app/types';
-import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE, DEFAULT_UI_STATE } from 'app/core/utils/explore';
+import {
+  LAST_USED_DATASOURCE_KEY,
+  ensureQueries,
+  DEFAULT_RANGE,
+  DEFAULT_UI_STATE,
+  getTimeRangeFromUrl,
+} from 'app/core/utils/explore';
 import { Emitter } from 'app/core/utils/emitter';
 import { ExploreToolbar } from './ExploreToolbar';
 import { scanStopAction } from './state/actionTypes';
 import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
 import { FadeIn } from 'app/core/components/Animations/FadeIn';
+import { getTimeZone } from '../profile/state/selectors';
 
 interface ExploreProps {
   StartPage?: ComponentClass<ExploreStartPageProps>;
@@ -53,7 +67,6 @@ interface ExploreProps {
   initializeExplore: typeof initializeExplore;
   initialized: boolean;
   modifyQueries: typeof modifyQueries;
-  range: RawTimeRange;
   update: ExploreUpdateState;
   reconnectDatasource: typeof reconnectDatasource;
   refreshExplore: typeof refreshExplore;
@@ -69,7 +82,10 @@ interface ExploreProps {
   supportsLogs: boolean | null;
   supportsTable: boolean | null;
   queryKeys: string[];
-  urlState: ExploreUrlState;
+  initialDatasource: string;
+  initialQueries: DataQuery[];
+  initialRange: RawTimeRange;
+  initialUI: ExploreUIState;
 }
 
 /**
@@ -111,12 +127,9 @@ export class Explore extends React.PureComponent<ExploreProps> {
   }
 
   componentDidMount() {
-    const { exploreId, urlState, initialized } = this.props;
-    const { datasource, queries, range = DEFAULT_RANGE, ui = DEFAULT_UI_STATE } = (urlState || {}) as ExploreUrlState;
-    const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
-    const initialQueries: DataQuery[] = ensureQueries(queries);
-    const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
+    const { initialized, exploreId, initialDatasource, initialQueries, initialRange, initialUI } = this.props;
     const width = this.el ? this.el.offsetWidth : 0;
+
     // initialize the whole explore first time we mount and if browser history contains a change in datasource
     if (!initialized) {
       this.props.initializeExplore(
@@ -126,7 +139,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
         initialRange,
         width,
         this.exploreEvents,
-        ui
+        initialUI
       );
     }
   }
@@ -143,7 +156,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
     this.el = el;
   };
 
-  onChangeTime = (range: TimeRange, changedByScanner?: boolean) => {
+  onChangeTime = (range: RawTimeRange, changedByScanner?: boolean) => {
     if (this.props.scanning && !changedByScanner) {
       this.onStopScanning();
     }
@@ -286,6 +299,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
   const explore = state.explore;
   const { split } = explore;
   const item: ExploreItemState = explore[exploreId];
+  const timeZone = getTimeZone(state.user);
   const {
     StartPage,
     datasourceError,
@@ -293,7 +307,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
     datasourceLoading,
     datasourceMissing,
     initialized,
-    range,
     showingStartPage,
     supportsGraph,
     supportsLogs,
@@ -302,6 +315,13 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
     urlState,
     update,
   } = item;
+
+  const { datasource, queries, range: urlRange, ui } = (urlState || {}) as ExploreUrlState;
+  const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
+  const initialQueries: DataQuery[] = ensureQueries(queries);
+  const initialRange = urlRange ? getTimeRangeFromUrl(urlRange, timeZone).raw : DEFAULT_RANGE;
+  const initialUI = ui || DEFAULT_UI_STATE;
+
   return {
     StartPage,
     datasourceError,
@@ -309,15 +329,17 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
     datasourceLoading,
     datasourceMissing,
     initialized,
-    range,
     showingStartPage,
     split,
     supportsGraph,
     supportsLogs,
     supportsTable,
     queryKeys,
-    urlState,
     update,
+    initialDatasource,
+    initialQueries,
+    initialRange,
+    initialUI,
   };
 }
 

+ 8 - 4
public/app/features/explore/ExploreToolbar.tsx

@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
 import { hot } from 'react-hot-loader';
 
 import { ExploreId } from 'app/types/explore';
-import { DataSourceSelectItem, RawTimeRange, TimeRange, ClickOutsideWrapper } from '@grafana/ui';
+import { DataSourceSelectItem, RawTimeRange, ClickOutsideWrapper, TimeZone, TimeRange } from '@grafana/ui';
 import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
 import { StoreState } from 'app/types/store';
 import {
@@ -15,6 +15,7 @@ import {
   changeRefreshInterval,
 } from './state/actions';
 import TimePicker from './TimePicker';
+import { getTimeZone } from '../profile/state/selectors';
 import { RefreshPicker, SetInterval } from '@grafana/ui';
 
 enum IconSide {
@@ -48,14 +49,15 @@ const createResponsiveButton = (options: {
 interface OwnProps {
   exploreId: ExploreId;
   timepickerRef: React.RefObject<TimePicker>;
-  onChangeTime: (range: TimeRange, changedByScanner?: boolean) => void;
+  onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void;
 }
 
 interface StateProps {
   datasourceMissing: boolean;
   exploreDatasources: DataSourceSelectItem[];
   loading: boolean;
-  range: RawTimeRange;
+  range: TimeRange;
+  timeZone: TimeZone;
   selectedDatasource: DataSourceSelectItem;
   splitted: boolean;
   refreshInterval: string;
@@ -106,6 +108,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
       exploreId,
       loading,
       range,
+      timeZone,
       selectedDatasource,
       splitted,
       timepickerRef,
@@ -159,7 +162,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
             ) : null}
             <div className="explore-toolbar-content-item timepicker">
               <ClickOutsideWrapper onClick={this.onCloseTimePicker}>
-                <TimePicker ref={timepickerRef} range={range} onChangeTime={onChangeTime} />
+                <TimePicker ref={timepickerRef} range={range} isUtc={timeZone.isUtc} onChangeTime={onChangeTime} />
               </ClickOutsideWrapper>
 
               <RefreshPicker
@@ -214,6 +217,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
     exploreDatasources,
     loading,
     range,
+    timeZone: getTimeZone(state.user),
     selectedDatasource,
     splitted,
     refreshInterval,

+ 3 - 1
public/app/features/explore/Graph.test.tsx

@@ -2,12 +2,14 @@ import React from 'react';
 import { shallow } from 'enzyme';
 import { Graph } from './Graph';
 import { mockData } from './__mocks__/mockData';
+import { DefaultTimeZone } from '@grafana/ui';
 
 const setup = (propOverrides?: object) => {
   const props = {
     size: { width: 10, height: 20 },
     data: mockData().slice(0, 19),
-    range: { from: 'now-6h', to: 'now' },
+    range: { from: 0, to: 1 },
+    timeZone: DefaultTimeZone,
     ...propOverrides,
   };
 

+ 14 - 22
public/app/features/explore/Graph.tsx

@@ -1,14 +1,12 @@
 import $ from 'jquery';
 import React, { PureComponent } from 'react';
-import moment from 'moment';
 
 import 'vendor/flot/jquery.flot';
 import 'vendor/flot/jquery.flot.time';
 import 'vendor/flot/jquery.flot.selection';
 import 'vendor/flot/jquery.flot.stack';
 
-import { RawTimeRange } from '@grafana/ui';
-import * as dateMath from 'app/core/utils/datemath';
+import { TimeZone, AbsoluteTimeRange } from '@grafana/ui';
 import TimeSeries from 'app/core/time_series2';
 
 import Legend from './Legend';
@@ -78,10 +76,11 @@ interface GraphProps {
   height?: number;
   width?: number;
   id?: string;
-  range: RawTimeRange;
+  range: AbsoluteTimeRange;
+  timeZone: TimeZone;
   split?: boolean;
   userOptions?: any;
-  onChangeTime?: (range: RawTimeRange) => void;
+  onChangeTime?: (range: AbsoluteTimeRange) => void;
   onToggleSeries?: (alias: string, hiddenSeries: Set<string>) => void;
 }
 
@@ -133,27 +132,20 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
   }
 
   onPlotSelected = (event, ranges) => {
-    if (this.props.onChangeTime) {
-      const range = {
-        from: moment(ranges.xaxis.from),
-        to: moment(ranges.xaxis.to),
-      };
-      this.props.onChangeTime(range);
+    const { onChangeTime } = this.props;
+    if (onChangeTime) {
+      this.props.onChangeTime({
+        from: ranges.xaxis.from,
+        to: ranges.xaxis.to,
+      });
     }
   };
 
   getDynamicOptions() {
-    const { range, width } = this.props;
+    const { range, width, timeZone } = this.props;
     const ticks = (width || 0) / 100;
-    let { from, to } = range;
-    if (!moment.isMoment(from)) {
-      from = dateMath.parse(from, false);
-    }
-    if (!moment.isMoment(to)) {
-      to = dateMath.parse(to, true);
-    }
-    const min = from.valueOf();
-    const max = to.valueOf();
+    const min = range.from;
+    const max = range.to;
     return {
       xaxis: {
         mode: 'time',
@@ -161,7 +153,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
         max: max,
         label: 'Datetime',
         ticks: ticks,
-        timezone: 'browser',
+        timezone: timeZone.raw,
         timeformat: time_format(ticks, min, max),
       },
     };

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

@@ -1,7 +1,8 @@
 import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
-import { TimeRange, RawTimeRange } from '@grafana/ui';
+import moment from 'moment';
+import { TimeRange, TimeZone, AbsoluteTimeRange } from '@grafana/ui';
 
 import { ExploreId, ExploreItemState } from 'app/types/explore';
 import { StoreState } from 'app/types';
@@ -9,12 +10,14 @@ import { StoreState } from 'app/types';
 import { toggleGraph, changeTime } from './state/actions';
 import Graph from './Graph';
 import Panel from './Panel';
+import { getTimeZone } from '../profile/state/selectors';
 
 interface GraphContainerProps {
   exploreId: ExploreId;
   graphResult?: any[];
   loading: boolean;
-  range: RawTimeRange;
+  range: TimeRange;
+  timeZone: TimeZone;
   showingGraph: boolean;
   showingTable: boolean;
   split: boolean;
@@ -28,13 +31,20 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
     this.props.toggleGraph(this.props.exploreId, this.props.showingGraph);
   };
 
-  onChangeTime = (timeRange: TimeRange) => {
-    this.props.changeTime(this.props.exploreId, timeRange);
+  onChangeTime = (absRange: AbsoluteTimeRange) => {
+    const { exploreId, timeZone, changeTime } = this.props;
+    const range = {
+      from: timeZone.isUtc ? moment.utc(absRange.from) : moment(absRange.from),
+      to: timeZone.isUtc ? moment.utc(absRange.to) : moment(absRange.to),
+    };
+
+    changeTime(exploreId, range);
   };
 
   render() {
-    const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width } = this.props;
+    const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width, timeZone } = this.props;
     const graphHeight = showingGraph && showingTable ? 200 : 400;
+    const timeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
 
     if (!graphResult) {
       return null;
@@ -47,7 +57,8 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
           height={graphHeight}
           id={`explore-graph-${exploreId}`}
           onChangeTime={this.onChangeTime}
-          range={range}
+          range={timeRange}
+          timeZone={timeZone}
           split={split}
           width={width}
         />
@@ -62,7 +73,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
   const item: ExploreItemState = explore[exploreId];
   const { graphResult, queryTransactions, range, showingGraph, showingTable } = item;
   const loading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
-  return { graphResult, loading, range, showingGraph, showingTable, split };
+  return { graphResult, loading, range, showingGraph, showingTable, split, timeZone: getTimeZone(state.user) };
 }
 
 const mapDispatchToProps = {

+ 11 - 4
public/app/features/explore/Logs.tsx

@@ -2,7 +2,7 @@ import _ from 'lodash';
 import React, { PureComponent } from 'react';
 
 import * as rangeUtil from 'app/core/utils/rangeutil';
-import { RawTimeRange, Switch, LogLevel } from '@grafana/ui';
+import { RawTimeRange, Switch, LogLevel, TimeZone, TimeRange, AbsoluteTimeRange } from '@grafana/ui';
 import TimeSeries from 'app/core/time_series2';
 
 import { LogsDedupDescription, LogsDedupStrategy, LogsModel, LogsMetaKind } from 'app/core/logs_model';
@@ -48,12 +48,13 @@ interface Props {
   exploreId: string;
   highlighterExpressions: string[];
   loading: boolean;
-  range?: RawTimeRange;
+  range: TimeRange;
+  timeZone: TimeZone;
   scanning?: boolean;
   scanRange?: RawTimeRange;
   dedupStrategy: LogsDedupStrategy;
   hiddenLogLevels: Set<LogLevel>;
-  onChangeTime?: (range: RawTimeRange) => void;
+  onChangeTime?: (range: AbsoluteTimeRange) => void;
   onClickLabel?: (label: string, value: string) => void;
   onStartScanning?: () => void;
   onStopScanning?: () => void;
@@ -156,6 +157,7 @@ export default class Logs extends PureComponent<Props, State> {
       loading = false,
       onClickLabel,
       range,
+      timeZone,
       scanning,
       scanRange,
       width,
@@ -191,6 +193,10 @@ export default class Logs extends PureComponent<Props, State> {
     // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
     const getRows = () => processedRows;
     const timeSeries = data.series.map(series => new TimeSeries(series));
+    const absRange = {
+      from: range.from.valueOf(),
+      to: range.to.valueOf(),
+    };
 
     return (
       <div className="logs-panel">
@@ -199,7 +205,8 @@ export default class Logs extends PureComponent<Props, State> {
             data={timeSeries}
             height={100}
             width={width}
-            range={range}
+            range={absRange}
+            timeZone={timeZone}
             id={`explore-logs-graph-${exploreId}`}
             onChangeTime={this.props.onChangeTime}
             onToggleSeries={this.onToggleLogLevel}

+ 9 - 3
public/app/features/explore/LogsContainer.tsx

@@ -1,7 +1,7 @@
 import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
-import { RawTimeRange, TimeRange, LogLevel } from '@grafana/ui';
+import { RawTimeRange, TimeRange, LogLevel, TimeZone, AbsoluteTimeRange } from '@grafana/ui';
 
 import { ExploreId, ExploreItemState } from 'app/types/explore';
 import { LogsModel, LogsDedupStrategy } from 'app/core/logs_model';
@@ -12,6 +12,7 @@ import Logs from './Logs';
 import Panel from './Panel';
 import { toggleLogLevelAction } from 'app/features/explore/state/actionTypes';
 import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors';
+import { getTimeZone } from '../profile/state/selectors';
 
 interface LogsContainerProps {
   exploreId: ExploreId;
@@ -19,11 +20,12 @@ interface LogsContainerProps {
   logsHighlighterExpressions?: string[];
   logsResult?: LogsModel;
   dedupedResult?: LogsModel;
-  onChangeTime: (range: TimeRange) => void;
+  onChangeTime: (range: AbsoluteTimeRange) => void;
   onClickLabel: (key: string, value: string) => void;
   onStartScanning: () => void;
   onStopScanning: () => void;
-  range: RawTimeRange;
+  range: TimeRange;
+  timeZone: TimeZone;
   scanning?: boolean;
   scanRange?: RawTimeRange;
   showingLogs: boolean;
@@ -64,6 +66,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
       onStartScanning,
       onStopScanning,
       range,
+      timeZone,
       showingLogs,
       scanning,
       scanRange,
@@ -88,6 +91,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
           onDedupStrategyChange={this.handleDedupStrategyChange}
           onToggleLogLevel={this.hangleToggleLogLevel}
           range={range}
+          timeZone={timeZone}
           scanning={scanning}
           scanRange={scanRange}
           width={width}
@@ -106,6 +110,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
   const { showingLogs, dedupStrategy } = exploreItemUIStateSelector(item);
   const hiddenLogLevels = new Set(item.hiddenLogLevels);
   const dedupedResult = deduplicatedLogsSelector(item);
+  const timeZone = getTimeZone(state.user);
 
   return {
     loading,
@@ -115,6 +120,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     scanRange,
     showingLogs,
     range,
+    timeZone,
     dedupStrategy,
     hiddenLogLevels,
     dedupedResult,

+ 8 - 4
public/app/features/explore/QueryEditor.tsx

@@ -1,5 +1,6 @@
 // Libraries
 import React, { PureComponent } from 'react';
+import moment from 'moment';
 
 // Services
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
@@ -7,7 +8,7 @@ import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
 
 // Types
 import { Emitter } from 'app/core/utils/emitter';
-import { RawTimeRange, DataQuery } from '@grafana/ui';
+import { DataQuery, TimeRange } from '@grafana/ui';
 import 'app/features/plugins/plugin_loader';
 
 interface QueryEditorProps {
@@ -17,7 +18,7 @@ interface QueryEditorProps {
   onQueryChange?: (value: DataQuery) => void;
   initialQuery: DataQuery;
   exploreEvents: Emitter;
-  range: RawTimeRange;
+  range: TimeRange;
 }
 
 export default class QueryEditor extends PureComponent<QueryEditorProps, any> {
@@ -62,10 +63,13 @@ export default class QueryEditor extends PureComponent<QueryEditorProps, any> {
     }
   }
 
-  initTimeSrv(range) {
+  initTimeSrv(range: TimeRange) {
     const timeSrv = getTimeSrv();
     timeSrv.init({
-      time: range,
+      time: {
+        from: moment(range.from),
+        to: moment(range.to),
+      },
       refresh: false,
       getTimezone: () => 'utc',
       timeRangeUpdated: () => console.log('refreshDashboard!'),

+ 2 - 9
public/app/features/explore/QueryRow.tsx

@@ -14,14 +14,7 @@ import { changeQuery, modifyQueries, runQueries, addQueryRow } from './state/act
 
 // Types
 import { StoreState } from 'app/types';
-import {
-  RawTimeRange,
-  DataQuery,
-  ExploreDataSourceApi,
-  QueryHint,
-  QueryFixAction,
-  DataSourceStatus,
-} from '@grafana/ui';
+import { DataQuery, ExploreDataSourceApi, QueryHint, QueryFixAction, DataSourceStatus, TimeRange } from '@grafana/ui';
 import { QueryTransaction, HistoryItem, ExploreItemState, ExploreId } from 'app/types/explore';
 import { Emitter } from 'app/core/utils/emitter';
 import { highlightLogsExpressionAction, removeQueryRowAction } from './state/actionTypes';
@@ -48,7 +41,7 @@ interface QueryRowProps {
   modifyQueries: typeof modifyQueries;
   queryTransactions: QueryTransaction[];
   exploreEvents: Emitter;
-  range: RawTimeRange;
+  range: TimeRange;
   removeQueryRowAction: typeof removeQueryRowAction;
   runQueries: typeof runQueries;
 }

+ 203 - 39
public/app/features/explore/TimePicker.test.tsx

@@ -1,74 +1,238 @@
 import React from 'react';
 import { shallow } from 'enzyme';
 import sinon from 'sinon';
+import moment from 'moment';
 
+import * as dateMath from 'app/core/utils/datemath';
 import * as rangeUtil from 'app/core/utils/rangeutil';
-import TimePicker, { DEFAULT_RANGE, parseTime } from './TimePicker';
+import TimePicker from './TimePicker';
+import { RawTimeRange, TimeRange, TIME_FORMAT } from '@grafana/ui';
+
+const DEFAULT_RANGE = {
+  from: 'now-6h',
+  to: 'now',
+};
+
+const fromRaw = (rawRange: RawTimeRange): TimeRange => {
+  const raw = {
+    from: moment.isMoment(rawRange.from) ? moment(rawRange.from) : rawRange.from,
+    to: moment.isMoment(rawRange.to) ? moment(rawRange.to) : rawRange.to,
+  };
+
+  return {
+    from: dateMath.parse(raw.from, false),
+    to: dateMath.parse(raw.to, true),
+    raw: rawRange,
+  };
+};
 
 describe('<TimePicker />', () => {
-  it('renders closed with default values', () => {
-    const rangeString = rangeUtil.describeTimeRange(DEFAULT_RANGE);
-    const wrapper = shallow(<TimePicker />);
-    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
-    expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBe(false);
+  it('render default values when closed and relative time range', () => {
+    const range = fromRaw(DEFAULT_RANGE);
+    const wrapper = shallow(<TimePicker range={range} />);
+    expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
+    expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
+    expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeFalsy();
+    expect(wrapper.find('.gf-timepicker-utc').exists()).toBeFalsy();
+  });
+
+  it('render default values when closed, utc and relative time range', () => {
+    const range = fromRaw(DEFAULT_RANGE);
+    const wrapper = shallow(<TimePicker range={range} isUtc />);
+    expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
+    expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
+    expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeFalsy();
+    expect(wrapper.find('.gf-timepicker-utc').exists()).toBeTruthy();
+  });
+
+  it('renders default values when open and relative range', () => {
+    const range = fromRaw(DEFAULT_RANGE);
+    const wrapper = shallow(<TimePicker range={range} isOpen />);
+    expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
+    expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
+    expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeTruthy();
+    expect(wrapper.find('.gf-timepicker-utc').exists()).toBeFalsy();
+    expect(wrapper.find('.timepicker-from').props().value).toBe(DEFAULT_RANGE.from);
+    expect(wrapper.find('.timepicker-to').props().value).toBe(DEFAULT_RANGE.to);
+  });
+
+  it('renders default values when open, utc and relative range', () => {
+    const range = fromRaw(DEFAULT_RANGE);
+    const wrapper = shallow(<TimePicker range={range} isOpen isUtc />);
+    expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
+    expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
+    expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeTruthy();
+    expect(wrapper.find('.gf-timepicker-utc').exists()).toBeTruthy();
+    expect(wrapper.find('.timepicker-from').props().value).toBe(DEFAULT_RANGE.from);
+    expect(wrapper.find('.timepicker-to').props().value).toBe(DEFAULT_RANGE.to);
   });
 
-  it('renders with relative range', () => {
+  it('apply with absolute range and non-utc', () => {
     const range = {
-      from: 'now-7h',
-      to: 'now',
+      from: moment.utc(1),
+      to: moment.utc(1000),
+      raw: {
+        from: moment.utc(1),
+        to: moment.utc(1000),
+      },
     };
-    const rangeString = rangeUtil.describeTimeRange(range);
-    const wrapper = shallow(<TimePicker range={range} isOpen />);
-    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
-    expect(wrapper.state('fromRaw')).toBe(range.from);
-    expect(wrapper.state('toRaw')).toBe(range.to);
-    expect(wrapper.find('.timepicker-from').props().value).toBe(range.from);
-    expect(wrapper.find('.timepicker-to').props().value).toBe(range.to);
+    const localRange = {
+      from: moment(1),
+      to: moment(1000),
+      raw: {
+        from: moment(1),
+        to: moment(1000),
+      },
+    };
+    const expectedRangeString = rangeUtil.describeTimeRange(localRange);
+
+    const onChangeTime = sinon.spy();
+    const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} />);
+    expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
+    expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
+    expect(wrapper.state('initialRange')).toBe(range.raw);
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe(expectedRangeString);
+    expect(wrapper.find('.timepicker-from').props().value).toBe(localRange.from.format(TIME_FORMAT));
+    expect(wrapper.find('.timepicker-to').props().value).toBe(localRange.to.format(TIME_FORMAT));
+
+    wrapper.find('button.gf-form-btn').simulate('click');
+    expect(onChangeTime.calledOnce).toBeTruthy();
+    expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(0);
+    expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(1000);
+
+    expect(wrapper.state('isOpen')).toBeFalsy();
+    expect(wrapper.state('rangeString')).toBe(expectedRangeString);
   });
 
-  it('renders with epoch (millies) range converted to ISO-ish', () => {
+  it('apply with absolute range and utc', () => {
     const range = {
-      from: '1',
-      to: '1000',
+      from: moment.utc(1),
+      to: moment.utc(1000),
+      raw: {
+        from: moment.utc(1),
+        to: moment.utc(1000),
+      },
     };
-    const rangeString = rangeUtil.describeTimeRange({
-      from: parseTime(range.from, true),
-      to: parseTime(range.to, true),
-    });
-    const wrapper = shallow(<TimePicker range={range} isUtc isOpen />);
+    const onChangeTime = sinon.spy();
+    const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
     expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
     expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:01');
-    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
+    expect(wrapper.state('initialRange')).toBe(range.raw);
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe('Jan 1, 1970 00:00:00 to Jan 1, 1970 00:00:01');
     expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:00');
     expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:01');
+
+    wrapper.find('button.gf-form-btn').simulate('click');
+    expect(onChangeTime.calledOnce).toBeTruthy();
+    expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(0);
+    expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(1000);
+
+    expect(wrapper.state('isOpen')).toBeFalsy();
+    expect(wrapper.state('rangeString')).toBe('Jan 1, 1970 00:00:00 to Jan 1, 1970 00:00:01');
   });
 
-  it('moves ranges forward and backward by half the range on arrow click', () => {
-    const range = {
-      from: '2000',
-      to: '4000',
+  it('moves ranges backward by half the range on left arrow click when utc', () => {
+    const rawRange = {
+      from: moment.utc(2000),
+      to: moment.utc(4000),
+      raw: {
+        from: moment.utc(2000),
+        to: moment.utc(4000),
+      },
     };
-    const rangeString = rangeUtil.describeTimeRange({
-      from: parseTime(range.from, true),
-      to: parseTime(range.to, true),
-    });
+    const range = fromRaw(rawRange);
 
     const onChangeTime = sinon.spy();
     const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
     expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
     expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
-    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
-    expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:02');
-    expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:04');
 
     wrapper.find('.timepicker-left').simulate('click');
-    expect(onChangeTime.calledOnce).toBe(true);
+    expect(onChangeTime.calledOnce).toBeTruthy();
+    expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(1000);
+    expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(3000);
+  });
+
+  it('moves ranges backward by half the range on left arrow click when not utc', () => {
+    const range = {
+      from: moment.utc(2000),
+      to: moment.utc(4000),
+      raw: {
+        from: moment.utc(2000),
+        to: moment.utc(4000),
+      },
+    };
+    const localRange = {
+      from: moment(2000),
+      to: moment(4000),
+      raw: {
+        from: moment(2000),
+        to: moment(4000),
+      },
+    };
+
+    const onChangeTime = sinon.spy();
+    const wrapper = shallow(<TimePicker range={range} isUtc={false} isOpen onChangeTime={onChangeTime} />);
+    expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
+    expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
+
+    wrapper.find('.timepicker-left').simulate('click');
+    expect(onChangeTime.calledOnce).toBeTruthy();
+    expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(1000);
+    expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(3000);
+  });
+
+  it('moves ranges forward by half the range on right arrow click when utc', () => {
+    const range = {
+      from: moment.utc(1000),
+      to: moment.utc(3000),
+      raw: {
+        from: moment.utc(1000),
+        to: moment.utc(3000),
+      },
+    };
+
+    const onChangeTime = sinon.spy();
+    const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
     expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:01');
     expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03');
 
     wrapper.find('.timepicker-right').simulate('click');
-    expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
-    expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
+    expect(onChangeTime.calledOnce).toBeTruthy();
+    expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(2000);
+    expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(4000);
+  });
+
+  it('moves ranges forward by half the range on right arrow click when not utc', () => {
+    const range = {
+      from: moment.utc(1000),
+      to: moment.utc(3000),
+      raw: {
+        from: moment.utc(1000),
+        to: moment.utc(3000),
+      },
+    };
+    const localRange = {
+      from: moment(1000),
+      to: moment(3000),
+      raw: {
+        from: moment(1000),
+        to: moment(3000),
+      },
+    };
+
+    const onChangeTime = sinon.spy();
+    const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} />);
+    expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
+    expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
+
+    wrapper.find('.timepicker-right').simulate('click');
+    expect(onChangeTime.calledOnce).toBeTruthy();
+    expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(2000);
+    expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(4000);
   });
 });

+ 91 - 107
public/app/features/explore/TimePicker.tsx

@@ -1,43 +1,12 @@
 import React, { PureComponent } from 'react';
 import moment from 'moment';
-
-import * as dateMath from 'app/core/utils/datemath';
 import * as rangeUtil from 'app/core/utils/rangeutil';
-import { Input, RawTimeRange, TimeRange } from '@grafana/ui';
-
-const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
-export const DEFAULT_RANGE = {
-  from: 'now-6h',
-  to: 'now',
-};
-
-/**
- * Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT).
- * @param value Epoch or relative time
- */
-export function parseTime(value: string | moment.Moment, isUtc = false, ensureString = false): string | moment.Moment {
-  if (moment.isMoment(value)) {
-    if (ensureString) {
-      return value.format(DATE_FORMAT);
-    }
-    return value;
-  }
-  if ((value as string).indexOf('now') !== -1) {
-    return value;
-  }
-  let time: any = value;
-  // Possible epoch
-  if (!isNaN(time)) {
-    time = parseInt(time, 10);
-  }
-  time = isUtc ? moment.utc(time) : moment(time);
-  return time.format(DATE_FORMAT);
-}
+import { Input, RawTimeRange, TimeRange, TIME_FORMAT } from '@grafana/ui';
 
 interface TimePickerProps {
   isOpen?: boolean;
   isUtc?: boolean;
-  range?: RawTimeRange;
+  range: TimeRange;
   onChangeTime?: (range: RawTimeRange, scanning?: boolean) => void;
 }
 
@@ -46,17 +15,40 @@ interface TimePickerState {
   isUtc: boolean;
   rangeString: string;
   refreshInterval?: string;
-  initialRange?: RawTimeRange;
+  initialRange: RawTimeRange;
 
   // Input-controlled text, keep these in a shape that is human-editable
   fromRaw: string;
   toRaw: string;
 }
 
+const getRaw = (isUtc: boolean, range: any) => {
+  const rawRange = {
+    from: range.raw.from,
+    to: range.raw.to,
+  };
+
+  if (moment.isMoment(rawRange.from)) {
+    if (!isUtc) {
+      rawRange.from = rawRange.from.local();
+    }
+    rawRange.from = rawRange.from.format(TIME_FORMAT);
+  }
+
+  if (moment.isMoment(rawRange.to)) {
+    if (!isUtc) {
+      rawRange.to = rawRange.to.local();
+    }
+    rawRange.to = rawRange.to.format(TIME_FORMAT);
+  }
+
+  return rawRange;
+};
+
 /**
  * TimePicker with dropdown menu for relative dates.
  *
- * Initialize with a range that is either based on relative time strings,
+ * Initialize with a range that is either based on relative rawRange.strings,
  * or on Moment objects.
  * Internally the component needs to keep a string representation in `fromRaw`
  * and `toRaw` for the controlled inputs.
@@ -69,89 +61,68 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
   constructor(props) {
     super(props);
 
+    const { range, isUtc, isOpen } = props;
+    const rawRange = getRaw(props.isUtc, range);
+
     this.state = {
-      isOpen: props.isOpen,
-      isUtc: props.isUtc,
-      rangeString: '',
-      fromRaw: '',
-      toRaw: '',
-      initialRange: DEFAULT_RANGE,
+      isOpen: isOpen,
+      isUtc: isUtc,
+      rangeString: rangeUtil.describeTimeRange(range.raw),
+      fromRaw: rawRange.from,
+      toRaw: rawRange.to,
+      initialRange: range.raw,
       refreshInterval: '',
     };
   } //Temp solution... How do detect if ds supports table format?
 
-  static getDerivedStateFromProps(props, state) {
-    if (state.initialRange && state.initialRange === props.range) {
+  static getDerivedStateFromProps(props: TimePickerProps, state: TimePickerState) {
+    if (
+      state.initialRange &&
+      state.initialRange.from === props.range.raw.from &&
+      state.initialRange.to === props.range.raw.to
+    ) {
       return state;
     }
 
-    const from = props.range ? props.range.from : DEFAULT_RANGE.from;
-    const to = props.range ? props.range.to : DEFAULT_RANGE.to;
-
-    // Ensure internal string format
-    const fromRaw = parseTime(from, props.isUtc, true);
-    const toRaw = parseTime(to, props.isUtc, true);
-    const range = {
-      from: fromRaw,
-      to: toRaw,
-    };
+    const { range } = props;
+    const rawRange = getRaw(props.isUtc, range);
 
     return {
       ...state,
-      fromRaw,
-      toRaw,
-      initialRange: props.range,
-      rangeString: rangeUtil.describeTimeRange(range),
+      fromRaw: rawRange.from,
+      toRaw: rawRange.to,
+      initialRange: range.raw,
+      rangeString: rangeUtil.describeTimeRange(range.raw),
     };
   }
 
   move(direction: number, scanning?: boolean): RawTimeRange {
-    const { onChangeTime } = this.props;
-    const { fromRaw, toRaw } = this.state;
-    const from = dateMath.parse(fromRaw, false);
-    const to = dateMath.parse(toRaw, true);
-    const step = scanning ? 1 : 2;
-    const timespan = (to.valueOf() - from.valueOf()) / step;
-
-    let nextTo, nextFrom;
+    const { onChangeTime, range: origRange } = this.props;
+    const range = {
+      from: moment.utc(origRange.from),
+      to: moment.utc(origRange.to),
+    };
+    const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
+    let to, from;
     if (direction === -1) {
-      nextTo = to.valueOf() - timespan;
-      nextFrom = from.valueOf() - timespan;
+      to = range.to.valueOf() - timespan;
+      from = range.from.valueOf() - timespan;
     } else if (direction === 1) {
-      nextTo = to.valueOf() + timespan;
-      nextFrom = from.valueOf() + timespan;
-      if (nextTo > Date.now() && to.valueOf() < Date.now()) {
-        nextTo = Date.now();
-        nextFrom = from.valueOf();
-      }
+      to = range.to.valueOf() + timespan;
+      from = range.from.valueOf() + timespan;
     } else {
-      nextTo = to.valueOf();
-      nextFrom = from.valueOf();
+      to = range.to.valueOf();
+      from = range.from.valueOf();
     }
 
-    const nextRange = {
-      from: moment(nextFrom),
-      to: moment(nextTo),
+    const nextTimeRange = {
+      from: this.props.isUtc ? moment.utc(from) : moment(from),
+      to: this.props.isUtc ? moment.utc(to) : moment(to),
     };
-
-    const nextTimeRange: TimeRange = {
-      raw: nextRange,
-      from: nextRange.from,
-      to: nextRange.to,
-    };
-
-    this.setState(
-      {
-        rangeString: rangeUtil.describeTimeRange(nextRange),
-        fromRaw: nextRange.from.format(DATE_FORMAT),
-        toRaw: nextRange.to.format(DATE_FORMAT),
-      },
-      () => {
-        onChangeTime(nextTimeRange, scanning);
-      }
-    );
-
-    return nextRange;
+    if (onChangeTime) {
+      onChangeTime(nextTimeRange);
+    }
+    return nextTimeRange;
   }
 
   handleChangeFrom = e => {
@@ -167,16 +138,25 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
   };
 
   handleClickApply = () => {
-    const { onChangeTime } = this.props;
-    let range;
+    const { onChangeTime, isUtc } = this.props;
+    let rawRange;
     this.setState(
       state => {
         const { toRaw, fromRaw } = this.state;
-        range = {
-          from: dateMath.parse(fromRaw, false),
-          to: dateMath.parse(toRaw, true),
+        rawRange = {
+          from: fromRaw,
+          to: toRaw,
         };
-        const rangeString = rangeUtil.describeTimeRange(range);
+
+        if (rawRange.from.indexOf('now') === -1) {
+          rawRange.from = isUtc ? moment.utc(rawRange.from, TIME_FORMAT) : moment(rawRange.from, TIME_FORMAT);
+        }
+
+        if (rawRange.to.indexOf('now') === -1) {
+          rawRange.to = isUtc ? moment.utc(rawRange.to, TIME_FORMAT) : moment(rawRange.to, TIME_FORMAT);
+        }
+
+        const rangeString = rangeUtil.describeTimeRange(rawRange);
         return {
           isOpen: false,
           rangeString,
@@ -184,7 +164,7 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
       },
       () => {
         if (onChangeTime) {
-          onChangeTime(range);
+          onChangeTime(rawRange);
         }
       }
     );
@@ -201,16 +181,20 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
   handleClickRelativeOption = range => {
     const { onChangeTime } = this.props;
     const rangeString = rangeUtil.describeTimeRange(range);
+    const rawRange = {
+      from: range.from,
+      to: range.to,
+    };
     this.setState(
       {
-        toRaw: range.to,
-        fromRaw: range.from,
+        toRaw: rawRange.to,
+        fromRaw: rawRange.from,
         isOpen: false,
         rangeString,
       },
       () => {
         if (onChangeTime) {
-          onChangeTime(range);
+          onChangeTime(rawRange);
         }
       }
     );

+ 2 - 2
public/app/features/explore/state/actionTypes.ts

@@ -2,12 +2,12 @@
 import { Emitter } from 'app/core/core';
 import {
   RawTimeRange,
-  TimeRange,
   DataQuery,
   DataSourceSelectItem,
   DataSourceApi,
   QueryFixAction,
   LogLevel,
+  TimeRange,
 } from '@grafana/ui/src/types';
 import {
   ExploreId,
@@ -89,7 +89,7 @@ export interface InitializeExplorePayload {
   containerWidth: number;
   eventBridge: Emitter;
   queries: DataQuery[];
-  range: RawTimeRange;
+  range: TimeRange;
   ui: ExploreUIState;
 }
 

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

@@ -1,3 +1,4 @@
+import moment from 'moment';
 import { refreshExplore, testDatasource, loadDatasource } from './actions';
 import { ExploreId, ExploreUrlState, ExploreUpdateState } from 'app/types';
 import { thunkTester } from 'test/core/thunk/thunkTester';
@@ -18,6 +19,7 @@ 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 } from '@grafana/ui';
 
 jest.mock('app/features/plugins/datasource_srv', () => ({
   getDatasourceSrv: () => ({
@@ -29,21 +31,39 @@ jest.mock('app/features/plugins/datasource_srv', () => ({
   }),
 }));
 
+const t = moment.utc();
+const testRange = {
+  from: t,
+  to: t,
+  raw: {
+    from: t,
+    to: t,
+  },
+};
+jest.mock('app/core/utils/explore', () => ({
+  ...jest.requireActual('app/core/utils/explore'),
+  getTimeRangeFromUrl: (range: RawTimeRange) => testRange,
+}));
+
 const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
   const exploreId = ExploreId.left;
   const containerWidth = 1920;
   const eventBridge = {} as Emitter;
   const ui = { dedupStrategy: LogsDedupStrategy.none, showingGraph: false, showingLogs: false, showingTable: false };
-  const range = { from: 'now', to: 'now' };
+  const timeZone = DefaultTimeZone;
+  const range = testRange;
   const urlState: ExploreUrlState = {
     datasource: 'some-datasource',
     queries: [],
-    range,
+    range: range.raw,
     ui,
   };
   const updateDefaults = makeInitialUpdateState();
   const update = { ...updateDefaults, ...updateOverides };
   const initialState = {
+    user: {
+      timeZone,
+    },
     explore: {
       [exploreId]: {
         initialized: true,
@@ -77,7 +97,7 @@ describe('refreshExplore', () => {
   describe('when explore is initialized', () => {
     describe('and update datasource is set', () => {
       it('then it should dispatch initializeExplore', async () => {
-        const { exploreId, ui, range, initialState, containerWidth, eventBridge } = setup({ datasource: true });
+        const { exploreId, ui, initialState, containerWidth, eventBridge } = setup({ datasource: true });
 
         const dispatchedActions = await thunkTester(initialState)
           .givenThunk(refreshExplore)
@@ -90,7 +110,10 @@ describe('refreshExplore', () => {
         expect(payload.containerWidth).toEqual(containerWidth);
         expect(payload.eventBridge).toEqual(eventBridge);
         expect(payload.queries.length).toBe(1); // Queries have generated keys hard to expect on
-        expect(payload.range).toEqual(range);
+        expect(payload.range.from).toEqual(testRange.from);
+        expect(payload.range.to).toEqual(testRange.to);
+        expect(payload.range.raw.from).toEqual(testRange.raw.from);
+        expect(payload.range.raw.to).toEqual(testRange.raw.to);
         expect(payload.ui).toEqual(ui);
       });
     });

+ 38 - 13
public/app/features/explore/state/actions.ts

@@ -1,5 +1,6 @@
 // Libraries
 import _ from 'lodash';
+import moment from 'moment';
 
 // Services & Utils
 import store from 'app/core/store';
@@ -16,6 +17,8 @@ import {
   buildQueryTransaction,
   serializeStateToUrlParam,
   parseUrlState,
+  getTimeRange,
+  getTimeRangeFromUrl,
 } from 'app/core/utils/explore';
 
 // Actions
@@ -26,12 +29,12 @@ import { ResultGetter } from 'app/types/explore';
 import { ThunkResult } from 'app/types';
 import {
   RawTimeRange,
-  TimeRange,
   DataSourceApi,
   DataQuery,
   DataSourceSelectItem,
   QueryHint,
   QueryFixAction,
+  TimeRange,
 } from '@grafana/ui/src/types';
 import {
   ExploreId,
@@ -83,7 +86,7 @@ import {
 } from './actionTypes';
 import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
 import { LogsDedupStrategy } from 'app/core/logs_model';
-import { parseTime } from '../TimePicker';
+import { getTimeZone } from 'app/features/profile/state/selectors';
 
 /**
  * Updates UI state and save it to the URL
@@ -169,8 +172,10 @@ export function changeSize(
 /**
  * Change the time range of Explore. Usually called from the Time picker or a graph interaction.
  */
-export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<void> {
-  return dispatch => {
+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));
   };
@@ -235,12 +240,14 @@ export function initializeExplore(
   exploreId: ExploreId,
   datasourceName: string,
   queries: DataQuery[],
-  range: RawTimeRange,
+  rawRange: RawTimeRange,
   containerWidth: number,
   eventBridge: Emitter,
   ui: ExploreUIState
 ): ThunkResult<void> {
-  return async dispatch => {
+  return async (dispatch, getState) => {
+    const timeZone = getTimeZone(getState().user);
+    const range = getTimeRange(timeZone, rawRange);
     dispatch(loadExploreDatasourcesAndSetDatasource(exploreId, datasourceName));
     dispatch(
       initializeExploreAction({
@@ -723,6 +730,23 @@ export function splitOpen(): ThunkResult<void> {
   };
 }
 
+const toRawTimeRange = (range: TimeRange): RawTimeRange => {
+  let from = range.raw.from;
+  if (moment.isMoment(from)) {
+    from = from.valueOf().toString(10);
+  }
+
+  let to = range.raw.to;
+  if (moment.isMoment(to)) {
+    to = to.valueOf().toString(10);
+  }
+
+  return {
+    from,
+    to,
+  };
+};
+
 /**
  * Saves Explore state to URL using the `left` and `right` parameters.
  * If split view is not active, `right` will not be set.
@@ -734,7 +758,7 @@ export function stateSave(): ThunkResult<void> {
     const leftUrlState: ExploreUrlState = {
       datasource: left.datasourceInstance.name,
       queries: left.queries.map(clearQueryKeys),
-      range: left.range,
+      range: toRawTimeRange(left.range),
       ui: {
         showingGraph: left.showingGraph,
         showingLogs: left.showingLogs,
@@ -747,7 +771,7 @@ export function stateSave(): ThunkResult<void> {
       const rightUrlState: ExploreUrlState = {
         datasource: right.datasourceInstance.name,
         queries: right.queries.map(clearQueryKeys),
-        range: right.range,
+        range: toRawTimeRange(right.range),
         ui: {
           showingGraph: right.showingGraph,
           showingLogs: right.showingLogs,
@@ -830,19 +854,20 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
     }
 
     const { urlState, update, containerWidth, eventBridge } = itemState;
-    const { datasource, queries, range, ui } = urlState;
+    const { datasource, queries, range: urlRange, ui } = urlState;
     const refreshQueries = queries.map(q => ({ ...q, ...generateEmptyQuery(itemState.queries) }));
-    const refreshRange = { from: parseTime(range.from), to: parseTime(range.to) };
+    const timeZone = getTimeZone(getState().user);
+    const range = getTimeRangeFromUrl(urlRange, timeZone);
+
     // need to refresh datasource
     if (update.datasource) {
       const initialQueries = ensureQueries(queries);
-      const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
-      dispatch(initializeExplore(exploreId, datasource, initialQueries, initialRange, containerWidth, eventBridge, ui));
+      dispatch(initializeExplore(exploreId, datasource, initialQueries, range, containerWidth, eventBridge, ui));
       return;
     }
 
     if (update.range) {
-      dispatch(changeTimeAction({ exploreId, range: refreshRange as TimeRange }));
+      dispatch(changeTimeAction({ exploreId, range }));
     }
 
     // need to refresh ui state

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

@@ -86,7 +86,11 @@ export const makeExploreItemState = (): ExploreItemState => ({
   initialized: false,
   queryTransactions: [],
   queryIntervals: { interval: '15s', intervalMs: DEFAULT_GRAPH_INTERVAL },
-  range: DEFAULT_RANGE,
+  range: {
+    from: null,
+    to: null,
+    raw: DEFAULT_RANGE,
+  },
   scanning: false,
   scanRange: null,
   showingGraph: true,

+ 1 - 0
public/app/features/profile/state/reducers.ts

@@ -3,6 +3,7 @@ import config from 'app/core/config';
 
 export const initialState: UserState = {
   orgId: config.bootData.user.orgId,
+  timeZone: config.bootData.user.timezone,
 };
 
 export const userReducer = (state = initialState, action: any): UserState => {

+ 4 - 0
public/app/features/profile/state/selectors.ts

@@ -0,0 +1,4 @@
+import { UserState } from 'app/types';
+import { parseTimeZone } from '@grafana/ui';
+
+export const getTimeZone = (state: UserState) => parseTimeZone(state.timeZone);

+ 2 - 2
public/app/types/explore.ts

@@ -2,7 +2,6 @@ import { ComponentClass } from 'react';
 import { Value } from 'slate';
 import {
   RawTimeRange,
-  TimeRange,
   DataQuery,
   DataQueryResponseData,
   DataSourceSelectItem,
@@ -10,6 +9,7 @@ import {
   QueryHint,
   ExploreStartPageProps,
   LogLevel,
+  TimeRange,
 } from '@grafana/ui';
 
 import { Emitter, TimeSeries } from 'app/core/core';
@@ -189,7 +189,7 @@ export interface ExploreItemState {
   /**
    * Time range for this Explore. Managed by the time picker and used by all query runs.
    */
-  range: TimeRange | RawTimeRange;
+  range: TimeRange;
   /**
    * Scanner function that calculates a new range, triggers a query run, and returns the new range.
    */

+ 1 - 0
public/app/types/user.ts

@@ -46,4 +46,5 @@ export interface UsersState {
 
 export interface UserState {
   orgId: number;
+  timeZone: string;
 }