Kaynağa Gözat

Explore: Uses new TimePicker from Grafana/UI (#17793)

* Wip: Intiail commit

* Refactor: Replaces TimePicker in Explore

* Refactor: Removes Angular TimePicker folder

* Refactor: Adds tests for getShiftedTimeRange

* Fix: Fixes invalid import to removed TimePicker

* Fix: Fixes dateTime tests

* Refactor: Reuses getShiftedTimeRange for both Explore and Dashboards

* Refactor: Shares getZoomedTimeRange between Explore and Dashboard
Hugo Häggmark 6 yıl önce
ebeveyn
işleme
ead4b1f5c7
27 değiştirilmiş dosya ile 302 ekleme ve 1032 silme
  1. 79 0
      public/app/core/utils/timePicker.test.ts
  2. 38 0
      public/app/core/utils/timePicker.ts
  3. 2 17
      public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx
  4. 0 189
      public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts
  5. 0 1
      public/app/features/dashboard/components/TimePicker/index.ts
  6. 0 24
      public/app/features/dashboard/components/TimePicker/settings.html
  7. 0 84
      public/app/features/dashboard/components/TimePicker/template.html
  8. 0 49
      public/app/features/dashboard/components/TimePicker/validation.ts
  9. 0 1
      public/app/features/dashboard/index.ts
  10. 2 6
      public/app/features/dashboard/services/TimeSrv.ts
  11. 5 21
      public/app/features/explore/Explore.tsx
  12. 112 0
      public/app/features/explore/ExploreTimeControls.tsx
  13. 12 29
      public/app/features/explore/ExploreToolbar.tsx
  14. 1 1
      public/app/features/explore/Logs.tsx
  15. 9 5
      public/app/features/explore/LogsContainer.tsx
  16. 0 238
      public/app/features/explore/TimePicker.test.tsx
  17. 0 305
      public/app/features/explore/TimePicker.tsx
  18. 1 16
      public/app/features/explore/state/actionTypes.ts
  19. 8 7
      public/app/features/explore/state/actions.ts
  20. 6 4
      public/app/features/explore/state/epics/processQueryResultsEpic.test.ts
  21. 11 5
      public/app/features/explore/state/epics/processQueryResultsEpic.ts
  22. 2 15
      public/app/features/explore/state/reducers.test.ts
  23. 1 9
      public/app/features/explore/state/reducers.ts
  24. 4 0
      public/app/store/configureStore.ts
  25. 0 6
      public/app/types/explore.ts
  26. 4 0
      public/sass/pages/_explore.scss
  27. 5 0
      public/test/core/redux/epicTester.ts

+ 79 - 0
public/app/core/utils/timePicker.test.ts

@@ -0,0 +1,79 @@
+import { toUtc, AbsoluteTimeRange } from '@grafana/ui';
+
+import { getShiftedTimeRange, getZoomedTimeRange } from './timePicker';
+
+export const setup = (options?: any) => {
+  const defaultOptions = {
+    range: {
+      from: toUtc('2019-01-01 10:00:00'),
+      to: toUtc('2019-01-01 16:00:00'),
+      raw: {
+        from: 'now-6h',
+        to: 'now',
+      },
+    },
+    direction: 0,
+  };
+
+  return { ...defaultOptions, ...options };
+};
+
+describe('getShiftedTimeRange', () => {
+  describe('when called with a direction of -1', () => {
+    it('then it should return correct result', () => {
+      const { range, direction } = setup({ direction: -1 });
+      const expectedRange: AbsoluteTimeRange = {
+        from: toUtc('2019-01-01 07:00:00').valueOf(),
+        to: toUtc('2019-01-01 13:00:00').valueOf(),
+      };
+
+      const result = getShiftedTimeRange(direction, range);
+
+      expect(result).toEqual(expectedRange);
+    });
+  });
+
+  describe('when called with a direction of 1', () => {
+    it('then it should return correct result', () => {
+      const { range, direction } = setup({ direction: 1 });
+      const expectedRange: AbsoluteTimeRange = {
+        from: toUtc('2019-01-01 13:00:00').valueOf(),
+        to: toUtc('2019-01-01 19:00:00').valueOf(),
+      };
+
+      const result = getShiftedTimeRange(direction, range);
+
+      expect(result).toEqual(expectedRange);
+    });
+  });
+
+  describe('when called with any other direction', () => {
+    it('then it should return correct result', () => {
+      const { range, direction } = setup({ direction: 0 });
+      const expectedRange: AbsoluteTimeRange = {
+        from: toUtc('2019-01-01 10:00:00').valueOf(),
+        to: toUtc('2019-01-01 16:00:00').valueOf(),
+      };
+
+      const result = getShiftedTimeRange(direction, range);
+
+      expect(result).toEqual(expectedRange);
+    });
+  });
+});
+
+describe('getZoomedTimeRange', () => {
+  describe('when called', () => {
+    it('then it should return correct result', () => {
+      const { range } = setup();
+      const expectedRange: AbsoluteTimeRange = {
+        from: toUtc('2019-01-01 07:00:00').valueOf(),
+        to: toUtc('2019-01-01 19:00:00').valueOf(),
+      };
+
+      const result = getZoomedTimeRange(range, 2);
+
+      expect(result).toEqual(expectedRange);
+    });
+  });
+});

+ 38 - 0
public/app/core/utils/timePicker.ts

@@ -0,0 +1,38 @@
+import { TimeRange, toUtc, AbsoluteTimeRange } from '@grafana/ui';
+
+export const getShiftedTimeRange = (direction: number, origRange: TimeRange): AbsoluteTimeRange => {
+  const range = {
+    from: toUtc(origRange.from),
+    to: toUtc(origRange.to),
+  };
+
+  const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
+  let to: number, from: number;
+
+  if (direction === -1) {
+    to = range.to.valueOf() - timespan;
+    from = range.from.valueOf() - timespan;
+  } else if (direction === 1) {
+    to = range.to.valueOf() + timespan;
+    from = range.from.valueOf() + timespan;
+    if (to > Date.now() && range.to.valueOf() < Date.now()) {
+      to = Date.now();
+      from = range.from.valueOf();
+    }
+  } else {
+    to = range.to.valueOf();
+    from = range.from.valueOf();
+  }
+
+  return { from, to };
+};
+
+export const getZoomedTimeRange = (range: TimeRange, factor: number): AbsoluteTimeRange => {
+  const timespan = range.to.valueOf() - range.from.valueOf();
+  const center = range.to.valueOf() - timespan / 2;
+
+  const to = center + (timespan * factor) / 2;
+  const from = center - (timespan * factor) / 2;
+
+  return { from, to };
+};

+ 2 - 17
public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx

@@ -16,6 +16,7 @@ import { TimePicker, RefreshPicker, RawTimeRange } from '@grafana/ui';
 // Utils & Services
 import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
 import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker';
+import { getShiftedTimeRange } from 'app/core/utils/timePicker';
 
 export interface Props {
   $injector: any;
@@ -44,23 +45,7 @@ export class DashNavTimeControls extends Component<Props> {
 
   onMoveTimePicker = (direction: number) => {
     const range = this.timeSrv.timeRange();
-    const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
-    let to: number, from: number;
-
-    if (direction === -1) {
-      to = range.to.valueOf() - timespan;
-      from = range.from.valueOf() - timespan;
-    } else if (direction === 1) {
-      to = range.to.valueOf() + timespan;
-      from = range.from.valueOf() + timespan;
-      if (to > Date.now() && range.to.valueOf() < Date.now()) {
-        to = Date.now();
-        from = range.from.valueOf();
-      }
-    } else {
-      to = range.to.valueOf();
-      from = range.from.valueOf();
-    }
+    const { from, to } = getShiftedTimeRange(direction, range);
 
     this.timeSrv.setTime({
       from: toUtc(from),

+ 0 - 189
public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts

@@ -1,189 +0,0 @@
-import _ from 'lodash';
-import angular from 'angular';
-
-import * as rangeUtil from '@grafana/ui/src/utils/rangeutil';
-
-export class TimePickerCtrl {
-  static tooltipFormat = 'MMM D, YYYY HH:mm:ss';
-  static defaults = {
-    time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
-    refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
-  };
-
-  dashboard: any;
-  panel: any;
-  absolute: any;
-  timeRaw: any;
-  editTimeRaw: any;
-  tooltip: string;
-  rangeString: string;
-  timeOptions: any;
-  refresh: any;
-  isUtc: boolean;
-  firstDayOfWeek: number;
-  isOpen: boolean;
-  isAbsolute: boolean;
-
-  /** @ngInject */
-  constructor(private $scope, private $rootScope, private timeSrv) {
-    this.$scope.ctrl = this;
-
-    $rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope);
-    $rootScope.onAppEvent('shift-time-backward', () => this.move(-1), $scope);
-    $rootScope.onAppEvent('closeTimepicker', this.openDropdown.bind(this), $scope);
-
-    this.dashboard.on('refresh', this.onRefresh.bind(this), $scope);
-
-    // init options
-    this.panel = this.dashboard.timepicker;
-    _.defaults(this.panel, TimePickerCtrl.defaults);
-    this.firstDayOfWeek = getLocaleData().firstDayOfWeek();
-
-    // init time stuff
-    this.onRefresh();
-  }
-
-  onRefresh() {
-    const time = angular.copy(this.timeSrv.timeRange());
-    const timeRaw = angular.copy(time.raw);
-
-    if (!this.dashboard.isTimezoneUtc()) {
-      time.from.local();
-      time.to.local();
-      if (isDateTime(timeRaw.from)) {
-        timeRaw.from.local();
-      }
-      if (isDateTime(timeRaw.to)) {
-        timeRaw.to.local();
-      }
-      this.isUtc = false;
-    } else {
-      this.isUtc = true;
-    }
-
-    this.rangeString = rangeUtil.describeTimeRange(timeRaw);
-    this.absolute = { fromJs: time.from.toDate(), toJs: time.to.toDate() };
-    this.tooltip = this.dashboard.formatDate(time.from) + ' <br>to<br>';
-    this.tooltip += this.dashboard.formatDate(time.to);
-    this.timeRaw = timeRaw;
-    this.isAbsolute = isDateTime(this.timeRaw.to);
-  }
-
-  zoom(factor) {
-    this.$rootScope.appEvent('zoom-out', 2);
-  }
-
-  move(direction) {
-    const range = this.timeSrv.timeRange();
-
-    const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
-    let to, from;
-    if (direction === -1) {
-      to = range.to.valueOf() - timespan;
-      from = range.from.valueOf() - timespan;
-    } else if (direction === 1) {
-      to = range.to.valueOf() + timespan;
-      from = range.from.valueOf() + timespan;
-      if (to > Date.now() && range.to < Date.now()) {
-        to = Date.now();
-        from = range.from.valueOf();
-      }
-    } else {
-      to = range.to.valueOf();
-      from = range.from.valueOf();
-    }
-
-    this.timeSrv.setTime({ from: toUtc(from), to: toUtc(to) });
-  }
-
-  openDropdown() {
-    if (this.isOpen) {
-      this.closeDropdown();
-      return;
-    }
-
-    this.onRefresh();
-    this.editTimeRaw = this.timeRaw;
-    this.timeOptions = rangeUtil.getRelativeTimesList(this.panel, this.rangeString);
-    this.refresh = {
-      value: this.dashboard.refresh,
-      options: this.panel.refresh_intervals.map((interval: any) => {
-        return { text: interval, value: interval };
-      }),
-    };
-
-    this.refresh.options.unshift({ text: 'off' });
-    this.isOpen = true;
-    this.$rootScope.appEvent('timepickerOpen');
-  }
-
-  closeDropdown() {
-    this.isOpen = false;
-    this.$rootScope.appEvent('timepickerClosed');
-  }
-
-  applyCustom() {
-    if (this.refresh.value !== this.dashboard.refresh) {
-      this.timeSrv.setAutoRefresh(this.refresh.value);
-    }
-
-    this.timeSrv.setTime(this.editTimeRaw);
-    this.closeDropdown();
-  }
-
-  absoluteFromChanged() {
-    this.editTimeRaw.from = this.getAbsoluteMomentForTimezone(this.absolute.fromJs);
-  }
-
-  absoluteToChanged() {
-    this.editTimeRaw.to = this.getAbsoluteMomentForTimezone(this.absolute.toJs);
-  }
-
-  getAbsoluteMomentForTimezone(jsDate) {
-    return this.dashboard.isTimezoneUtc() ? dateTime(jsDate).utc() : dateTime(jsDate);
-  }
-
-  setRelativeFilter(timespan) {
-    const range = { from: timespan.from, to: timespan.to };
-
-    if (this.panel.nowDelay && range.to === 'now') {
-      range.to = 'now-' + this.panel.nowDelay;
-    }
-
-    this.timeSrv.setTime(range);
-    this.closeDropdown();
-  }
-}
-
-export function settingsDirective() {
-  return {
-    restrict: 'E',
-    templateUrl: 'public/app/features/dashboard/components/TimePicker/settings.html',
-    controller: TimePickerCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-    scope: {
-      dashboard: '=',
-    },
-  };
-}
-
-export function timePickerDirective() {
-  return {
-    restrict: 'E',
-    templateUrl: 'public/app/features/dashboard/components/TimePicker/template.html',
-    controller: TimePickerCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-    scope: {
-      dashboard: '=',
-    },
-  };
-}
-
-angular.module('grafana.directives').directive('gfTimePickerSettings', settingsDirective);
-angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective);
-
-import { inputDateDirective } from './validation';
-import { toUtc, getLocaleData, isDateTime, dateTime } from '@grafana/ui/src/utils/moment_wrapper';
-angular.module('grafana.directives').directive('inputDatetime', inputDateDirective);

+ 0 - 1
public/app/features/dashboard/components/TimePicker/index.ts

@@ -1 +0,0 @@
-export { TimePickerCtrl } from './TimePickerCtrl';

+ 0 - 24
public/app/features/dashboard/components/TimePicker/settings.html

@@ -1,24 +0,0 @@
-<div class="editor-row">
-	<h5 class="section-heading">Time Options</h5>
-
-  <div class="gf-form-group">
-		<div class="gf-form">
-			<label class="gf-form-label width-10">Timezone</label>
-			<div class="gf-form-select-wrapper">
-				<select ng-model="ctrl.dashboard.timezone" class='gf-form-input' ng-options="f.value as f.text for f in [{value: '', text: 'Default'}, {value: 'browser', text: 'Local browser time'},{value: 'utc', text: 'UTC'}]"></select>
-			</div>
-		</div>
-
-		<div class="gf-form">
-			<span class="gf-form-label width-10">Auto-refresh</span>
-			<input type="text" class="gf-form-input max-width-25" ng-model="ctrl.panel.refresh_intervals" array-join>
-		</div>
-		<div class="gf-form">
-			<span class="gf-form-label width-10">Now delay now-</span>
-			<input type="text" class="gf-form-input max-width-25" ng-model="ctrl.panel.nowDelay" placeholder="0m" valid-time-span bs-tooltip="'Enter 1m to ignore the last minute (because it can contain incomplete metrics)'"
-																																																												 data-placement="right">
-		</div>
-
-		<gf-form-switch class="gf-form" label="Hide time picker" checked="ctrl.panel.hidden" label-class="width-10"></gf-form-switch>
-	</div>
-</div>

+ 0 - 84
public/app/features/dashboard/components/TimePicker/template.html

@@ -1,84 +0,0 @@
-<button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(-1)' ng-if="ctrl.isAbsolute">
-  <i class="fa fa-chevron-left"></i>
-</button>
-
-<button bs-tooltip="ctrl.tooltip" data-placement="bottom" ng-click="ctrl.openDropdown()" class="btn navbar-button gf-timepicker-nav-btn">
-  <i class="fa fa-clock-o"></i>
-  <span ng-bind="ctrl.rangeString"></span>
-  <span ng-show="ctrl.isUtc" class="gf-timepicker-utc">UTC</span>
-  <!-- <span ng-show="ctrl.dashboard.refresh" class="text-warning">&nbsp; Refresh every {{ctrl.dashboard.refresh}}</span> -->
-</button>
-
-<button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(1)' ng-if="ctrl.isAbsolute">
-  <i class="fa fa-chevron-right"></i>
-</button>
-
-<button class="btn navbar-button navbar-button--zoom" bs-tooltip="'Time range zoom out <br> CTRL+Z'" data-placement="bottom" ng-click='ctrl.zoom(2)'>
-  <i class="fa fa-search-minus"></i>
-</button>
-
-<!-- <button class="btn navbar-button navbar-button--refresh" ng-click="ctrl.timeSrv.refreshDashboard()">
-  <i class="fa fa-refresh"></i>
-</button> -->
-
-<div ng-if="ctrl.isOpen" class="gf-timepicker-dropdown">
-  <div class="popover-box">
-    <div class="popover-box__header">
-      <span class="popover-box__title">Quick ranges</span>
-    </div>
-    <div class="popover-box__body gf-timepicker-relative-section">
-      <ul ng-repeat="group in ctrl.timeOptions">
-        <li bindonce ng-repeat='option in group' ng-class="{active: option.active}">
-          <a ng-click="ctrl.setRelativeFilter(option)" bo-text="option.display"></a>
-        </li>
-      </ul>
-    </div>
-  </div>
-
-  <div class="popover-box">
-    <div class="popover-box__header">
-      <span class="popover-box__title">Custom range</span>
-    </div>
-    <form name="timeForm" class="popover-box__body gf-timepicker-absolute-section max-width-28">
-      <label class="small">From:</label>
-      <div class="gf-form-inline">
-        <div class="gf-form max-width-28">
-          <input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.from" input-datetime>
-        </div>
-        <div class="gf-form">
-          <button class="btn gf-form-btn btn-secondary" type="button" ng-click="openFromPicker=!openFromPicker">
-            <i class="fa fa-calendar"></i>
-          </button>
-        </div>
-      </div>
-
-      <div ng-if="openFromPicker">
-        <datepicker ng-model="ctrl.absolute.fromJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteFromChanged()"></datepicker>
-      </div>
-
-
-      <label class="small">To:</label>
-      <div class="gf-form-inline">
-        <div class="gf-form max-width-28">
-          <input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.to" input-datetime>
-        </div>
-        <div class="gf-form">
-          <button class="btn gf-form-btn btn-secondary" type="button" ng-click="openToPicker=!openToPicker">
-            <i class="fa fa-calendar"></i>
-          </button>
-        </div>
-      </div>
-
-      <div ng-if="openToPicker">
-        <datepicker ng-model="ctrl.absolute.toJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteToChanged()"></datepicker>
-      </div>
-
-      <div class="gf-form gf-form--flex-end m-t-1">
-        <div class="gf-form">
-          <button type="submit" class="btn gf-form-btn btn-primary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
-        </div>
-      </div>
-    </form>
-  </div>
-</div>
-

+ 0 - 49
public/app/features/dashboard/components/TimePicker/validation.ts

@@ -1,49 +0,0 @@
-import * as dateMath from '@grafana/ui/src/utils/datemath';
-import { toUtc, dateTime, isDateTime } from '@grafana/ui/src/utils/moment_wrapper';
-
-export function inputDateDirective() {
-  return {
-    restrict: 'A',
-    require: 'ngModel',
-    link: ($scope, $elem, attrs, ngModel) => {
-      const format = 'YYYY-MM-DD HH:mm:ss';
-
-      const fromUser = text => {
-        if (text.indexOf('now') !== -1) {
-          if (!dateMath.isValid(text)) {
-            ngModel.$setValidity('error', false);
-            return undefined;
-          }
-          ngModel.$setValidity('error', true);
-          return text;
-        }
-
-        let parsed;
-        if ($scope.ctrl.isUtc) {
-          parsed = toUtc(text, format);
-        } else {
-          parsed = dateTime(text, format);
-        }
-
-        if (!parsed.isValid()) {
-          ngModel.$setValidity('error', false);
-          return undefined;
-        }
-
-        ngModel.$setValidity('error', true);
-        return parsed;
-      };
-
-      const toUser = currentValue => {
-        if (isDateTime(currentValue)) {
-          return currentValue.format(format);
-        } else {
-          return currentValue;
-        }
-      };
-
-      ngModel.$parsers.push(fromUser);
-      ngModel.$formatters.push(toUser);
-    },
-  };
-}

+ 0 - 1
public/app/features/dashboard/index.ts

@@ -12,7 +12,6 @@ import './components/FolderPicker';
 import './components/VersionHistory';
 import './components/DashboardSettings';
 import './components/SubMenu';
-import './components/TimePicker';
 import './components/UnsavedChangesModal';
 import './components/SaveModals';
 import './components/ShareModal';

+ 2 - 6
public/app/features/dashboard/services/TimeSrv.ts

@@ -12,6 +12,7 @@ import { ITimeoutService, ILocationService } from 'angular';
 import { ContextSrv } from 'app/core/services/context_srv';
 import { DashboardModel } from '../state/DashboardModel';
 import { toUtc, dateTime, isDateTime } from '@grafana/ui/src/utils/moment_wrapper';
+import { getZoomedTimeRange } from 'app/core/utils/timePicker';
 
 export class TimeSrv {
   time: any;
@@ -238,12 +239,7 @@ export class TimeSrv {
 
   zoomOut(e: any, factor: number) {
     const range = this.timeRange();
-
-    const timespan = range.to.valueOf() - range.from.valueOf();
-    const center = range.to.valueOf() - timespan / 2;
-
-    const to = center + (timespan * factor) / 2;
-    const from = center - (timespan * factor) / 2;
+    const { from, to } = getZoomedTimeRange(range, factor);
 
     this.setTime({ from: toUtc(from), to: toUtc(to) });
   }

+ 5 - 21
public/app/features/explore/Explore.tsx

@@ -16,7 +16,6 @@ import GraphContainer from './GraphContainer';
 import LogsContainer from './LogsContainer';
 import QueryRows from './QueryRows';
 import TableContainer from './TableContainer';
-import TimePicker from './TimePicker';
 
 // Actions
 import {
@@ -35,7 +34,6 @@ import { RawTimeRange, DataQuery, ExploreStartPageProps, DataSourceApi, DataQuer
 import {
   ExploreItemState,
   ExploreUrlState,
-  RangeScanner,
   ExploreId,
   ExploreUpdateState,
   ExploreUIState,
@@ -71,7 +69,6 @@ interface ExploreProps {
   update: ExploreUpdateState;
   reconnectDatasource: typeof reconnectDatasource;
   refreshExplore: typeof refreshExplore;
-  scanner?: RangeScanner;
   scanning?: boolean;
   scanRange?: RawTimeRange;
   scanStart: typeof scanStart;
@@ -117,15 +114,10 @@ interface ExploreProps {
 export class Explore extends React.PureComponent<ExploreProps> {
   el: any;
   exploreEvents: Emitter;
-  /**
-   * Timepicker to control scanning
-   */
-  timepickerRef: React.RefObject<TimePicker>;
 
   constructor(props: ExploreProps) {
     super(props);
     this.exploreEvents = new Emitter();
-    this.timepickerRef = React.createRef();
   }
 
   componentDidMount() {
@@ -159,11 +151,9 @@ export class Explore extends React.PureComponent<ExploreProps> {
     this.el = el;
   };
 
-  onChangeTime = (rawRange: RawTimeRange, changedByScanner?: boolean) => {
-    const { updateTimeRange, exploreId, scanning } = this.props;
-    if (scanning && !changedByScanner) {
-      this.onStopScanning();
-    }
+  onChangeTime = (rawRange: RawTimeRange) => {
+    const { updateTimeRange, exploreId } = this.props;
+
     updateTimeRange({ exploreId, rawRange });
   };
 
@@ -190,13 +180,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
 
   onStartScanning = () => {
     // Scanner will trigger a query
-    const scanner = this.scanPreviousRange;
-    this.props.scanStart(this.props.exploreId, scanner);
-  };
-
-  scanPreviousRange = (): RawTimeRange => {
-    // Calling move() on the timepicker will trigger this.onChangeTime()
-    return this.timepickerRef.current.move(-1, true);
+    this.props.scanStart(this.props.exploreId);
   };
 
   onStopScanning = () => {
@@ -244,7 +228,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
 
     return (
       <div className={exploreClass} ref={this.getRef}>
-        <ExploreToolbar exploreId={exploreId} timepickerRef={this.timepickerRef} onChangeTime={this.onChangeTime} />
+        <ExploreToolbar exploreId={exploreId} onChangeTime={this.onChangeTime} />
         {datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
         {datasourceMissing ? this.renderEmptyState() : null}
 

+ 112 - 0
public/app/features/explore/ExploreTimeControls.tsx

@@ -0,0 +1,112 @@
+// Libaries
+import React, { Component } from 'react';
+
+// Types
+import { ExploreId } from 'app/types';
+import { TimeRange, TimeOption, TimeZone, SetInterval, toUtc, dateTime } from '@grafana/ui';
+
+// State
+
+// Components
+import { TimePicker, RefreshPicker, RawTimeRange } from '@grafana/ui';
+
+// Utils & Services
+import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker';
+import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
+
+export interface Props {
+  exploreId: ExploreId;
+  hasLiveOption: boolean;
+  isLive: boolean;
+  loading: boolean;
+  range: TimeRange;
+  refreshInterval: string;
+  timeZone: TimeZone;
+  onRunQuery: () => void;
+  onChangeRefreshInterval: (interval: string) => void;
+  onChangeTime: (range: RawTimeRange) => void;
+}
+
+export class ExploreTimeControls extends Component<Props> {
+  onMoveTimePicker = (direction: number) => {
+    const { range, onChangeTime, timeZone } = this.props;
+    const { from, to } = getShiftedTimeRange(direction, range);
+    const nextTimeRange = {
+      from: timeZone === 'utc' ? toUtc(from) : dateTime(from),
+      to: timeZone === 'utc' ? toUtc(to) : dateTime(to),
+    };
+
+    onChangeTime(nextTimeRange);
+  };
+
+  onMoveForward = () => this.onMoveTimePicker(1);
+  onMoveBack = () => this.onMoveTimePicker(-1);
+
+  onChangeTimePicker = (timeRange: TimeRange) => {
+    this.props.onChangeTime(timeRange.raw);
+  };
+
+  onZoom = () => {
+    const { range, onChangeTime, timeZone } = this.props;
+    const { from, to } = getZoomedTimeRange(range, 2);
+    const nextTimeRange = {
+      from: timeZone === 'utc' ? toUtc(from) : dateTime(from),
+      to: timeZone === 'utc' ? toUtc(to) : dateTime(to),
+    };
+
+    onChangeTime(nextTimeRange);
+  };
+
+  setActiveTimeOption = (timeOptions: TimeOption[], rawTimeRange: RawTimeRange): TimeOption[] => {
+    return timeOptions.map(option => {
+      if (option.to === rawTimeRange.to && option.from === rawTimeRange.from) {
+        return {
+          ...option,
+          active: true,
+        };
+      }
+      return {
+        ...option,
+        active: false,
+      };
+    });
+  };
+
+  render() {
+    const {
+      hasLiveOption,
+      isLive,
+      loading,
+      range,
+      refreshInterval,
+      timeZone,
+      onRunQuery,
+      onChangeRefreshInterval,
+    } = this.props;
+
+    return (
+      <>
+        {!isLive && (
+          <TimePicker
+            value={range}
+            onChange={this.onChangeTimePicker}
+            timeZone={timeZone}
+            onMoveBackward={this.onMoveBack}
+            onMoveForward={this.onMoveForward}
+            onZoom={this.onZoom}
+            selectOptions={this.setActiveTimeOption(defaultSelectOptions, range.raw)}
+          />
+        )}
+
+        <RefreshPicker
+          onIntervalChanged={onChangeRefreshInterval}
+          onRefresh={onRunQuery}
+          value={refreshInterval}
+          tooltip="Refresh"
+          hasLiveOption={hasLiveOption}
+        />
+        {refreshInterval && <SetInterval func={onRunQuery} interval={refreshInterval} loading={loading} />}
+      </>
+    );
+  }
+}

+ 12 - 29
public/app/features/explore/ExploreToolbar.tsx

@@ -3,15 +3,7 @@ import { connect } from 'react-redux';
 import { hot } from 'react-hot-loader';
 
 import { ExploreId, ExploreMode } from 'app/types/explore';
-import {
-  DataSourceSelectItem,
-  RawTimeRange,
-  ClickOutsideWrapper,
-  TimeZone,
-  TimeRange,
-  SelectOptionItem,
-  LoadingState,
-} from '@grafana/ui';
+import { DataSourceSelectItem, RawTimeRange, TimeZone, TimeRange, SelectOptionItem, LoadingState } from '@grafana/ui';
 import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
 import { StoreState } from 'app/types/store';
 import {
@@ -23,10 +15,9 @@ import {
   changeRefreshInterval,
   changeMode,
 } from './state/actions';
-import TimePicker from './TimePicker';
 import { getTimeZone } from '../profile/state/selectors';
-import { RefreshPicker, SetInterval } from '@grafana/ui';
 import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
+import { ExploreTimeControls } from './ExploreTimeControls';
 
 enum IconSide {
   left = 'left',
@@ -63,7 +54,6 @@ const createResponsiveButton = (options: {
 
 interface OwnProps {
   exploreId: ExploreId;
-  timepickerRef: React.RefObject<TimePicker>;
   onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void;
 }
 
@@ -111,10 +101,6 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
     return this.props.runQueries(this.props.exploreId);
   };
 
-  onCloseTimePicker = () => {
-    this.props.timepickerRef.current.setState({ isOpen: false });
-  };
-
   onChangeRefreshInterval = (item: string) => {
     const { changeRefreshInterval, exploreId } = this.props;
     changeRefreshInterval(exploreId, item);
@@ -136,7 +122,6 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
       timeZone,
       selectedDatasource,
       splitted,
-      timepickerRef,
       refreshInterval,
       onChangeTime,
       split,
@@ -214,20 +199,18 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
               </div>
             ) : null}
             <div className="explore-toolbar-content-item timepicker">
-              {!isLive && (
-                <ClickOutsideWrapper onClick={this.onCloseTimePicker}>
-                  <TimePicker ref={timepickerRef} range={range} timeZone={timeZone} onChangeTime={onChangeTime} />
-                </ClickOutsideWrapper>
-              )}
-
-              <RefreshPicker
-                onIntervalChanged={this.onChangeRefreshInterval}
-                onRefresh={this.onRunQuery}
-                value={refreshInterval}
-                tooltip="Refresh"
+              <ExploreTimeControls
+                exploreId={exploreId}
                 hasLiveOption={hasLiveOption}
+                isLive={isLive}
+                loading={loading}
+                range={range}
+                refreshInterval={refreshInterval}
+                timeZone={timeZone}
+                onChangeTime={onChangeTime}
+                onChangeRefreshInterval={this.onChangeRefreshInterval}
+                onRunQuery={this.onRunQuery}
               />
-              {refreshInterval && <SetInterval func={this.onRunQuery} interval={refreshInterval} loading={loading} />}
             </div>
 
             <div className="explore-toolbar-content-item">

+ 1 - 1
public/app/features/explore/Logs.tsx

@@ -101,7 +101,7 @@ export default class Logs extends PureComponent<Props, State> {
     }
   }
 
-  componentDidUpdate(prevProps, prevState) {
+  componentDidUpdate(prevProps: Props, prevState: State) {
     // Staged rendering
     if (prevState.deferLogs && !this.state.deferLogs && !this.state.renderAll) {
       this.renderAllTimer = setTimeout(() => this.setState({ renderAll: true }), 2000);

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

@@ -11,6 +11,7 @@ import {
   LogRowModel,
   LogsDedupStrategy,
   LoadingState,
+  TimeRange,
 } from '@grafana/ui';
 
 import { ExploreId, ExploreItemState } from 'app/types/explore';
@@ -47,6 +48,7 @@ interface LogsContainerProps {
   isLive: boolean;
   stopLive: typeof changeRefreshIntervalAction;
   updateTimeRange: typeof updateTimeRange;
+  range: TimeRange;
   absoluteRange: AbsoluteTimeRange;
 }
 
@@ -90,7 +92,9 @@ export class LogsContainer extends Component<LogsContainerProps> {
     return (
       nextProps.loading !== this.props.loading ||
       nextProps.dedupStrategy !== this.props.dedupStrategy ||
-      nextProps.logsHighlighterExpressions !== this.props.logsHighlighterExpressions
+      nextProps.logsHighlighterExpressions !== this.props.logsHighlighterExpressions ||
+      nextProps.scanning !== this.props.scanning ||
+      nextProps.isLive !== this.props.isLive
     );
   }
 
@@ -107,7 +111,7 @@ export class LogsContainer extends Component<LogsContainerProps> {
       absoluteRange,
       timeZone,
       scanning,
-      scanRange,
+      range,
       width,
       hiddenLogLevels,
       isLive,
@@ -139,7 +143,7 @@ export class LogsContainer extends Component<LogsContainerProps> {
           absoluteRange={absoluteRange}
           timeZone={timeZone}
           scanning={scanning}
-          scanRange={scanRange}
+          scanRange={range.raw}
           width={width}
           hiddenLogLevels={hiddenLogLevels}
           getRowContext={this.getLogRowContext}
@@ -157,9 +161,9 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     logsResult,
     loadingState,
     scanning,
-    scanRange,
     datasourceInstance,
     isLive,
+    range,
     absoluteRange,
   } = item;
   const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming;
@@ -173,13 +177,13 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     logsHighlighterExpressions,
     logsResult,
     scanning,
-    scanRange,
     timeZone,
     dedupStrategy,
     hiddenLogLevels,
     dedupedResult,
     datasourceInstance,
     isLive,
+    range,
     absoluteRange,
   };
 }

+ 0 - 238
public/app/features/explore/TimePicker.test.tsx

@@ -1,238 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import sinon from 'sinon';
-
-import * as dateMath from '@grafana/ui/src/utils/datemath';
-import * as rangeUtil from '@grafana/ui/src/utils/rangeutil';
-import TimePicker from './TimePicker';
-import { RawTimeRange, TimeRange, TIME_FORMAT } from '@grafana/ui';
-import { toUtc, isDateTime, dateTime } from '@grafana/ui/src/utils/moment_wrapper';
-
-const DEFAULT_RANGE = {
-  from: 'now-6h',
-  to: 'now',
-};
-
-const fromRaw = (rawRange: RawTimeRange): TimeRange => {
-  const raw = {
-    from: isDateTime(rawRange.from) ? dateTime(rawRange.from) : rawRange.from,
-    to: isDateTime(rawRange.to) ? dateTime(rawRange.to) : rawRange.to,
-  };
-
-  return {
-    from: dateMath.parse(raw.from, false),
-    to: dateMath.parse(raw.to, true),
-    raw: rawRange,
-  };
-};
-
-describe('<TimePicker />', () => {
-  it('render default values when closed and relative time range', () => {
-    const range = fromRaw(DEFAULT_RANGE);
-    const wrapper = shallow(<TimePicker range={range} timeZone="browser" />);
-    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} timeZone="utc" />);
-    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 timeZone="browser" />);
-    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 timeZone="utc" />);
-    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('apply with absolute range and non-utc', () => {
-    const range = {
-      from: toUtc(1),
-      to: toUtc(1000),
-      raw: {
-        from: toUtc(1),
-        to: toUtc(1000),
-      },
-    };
-    const localRange = {
-      from: dateTime(1),
-      to: dateTime(1000),
-      raw: {
-        from: dateTime(1),
-        to: dateTime(1000),
-      },
-    };
-    const expectedRangeString = rangeUtil.describeTimeRange(localRange);
-
-    const onChangeTime = sinon.spy();
-    const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} timeZone="browser" />);
-    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('apply with absolute range and utc', () => {
-    const range = {
-      from: toUtc(1),
-      to: toUtc(1000),
-      raw: {
-        from: toUtc(1),
-        to: toUtc(1000),
-      },
-    };
-    const onChangeTime = sinon.spy();
-    const wrapper = shallow(<TimePicker range={range} timeZone="utc" 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.state('initialRange')).toBe(range.raw);
-    expect(wrapper.find('.timepicker-rangestring').text()).toBe('1970-01-01 00:00:00 to 1970-01-01 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('1970-01-01 00:00:00 to 1970-01-01 00:00:01');
-  });
-
-  it('moves ranges backward by half the range on left arrow click when utc', () => {
-    const rawRange = {
-      from: toUtc(2000),
-      to: toUtc(4000),
-      raw: {
-        from: toUtc(2000),
-        to: toUtc(4000),
-      },
-    };
-    const range = fromRaw(rawRange);
-
-    const onChangeTime = sinon.spy();
-    const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} timeZone="utc" />);
-    expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
-    expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
-
-    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 backward by half the range on left arrow click when not utc', () => {
-    const range = {
-      from: toUtc(2000),
-      to: toUtc(4000),
-      raw: {
-        from: toUtc(2000),
-        to: toUtc(4000),
-      },
-    };
-    const localRange = {
-      from: dateTime(2000),
-      to: dateTime(4000),
-      raw: {
-        from: dateTime(2000),
-        to: dateTime(4000),
-      },
-    };
-
-    const onChangeTime = sinon.spy();
-    const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} timeZone="browser" />);
-    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: toUtc(1000),
-      to: toUtc(3000),
-      raw: {
-        from: toUtc(1000),
-        to: toUtc(3000),
-      },
-    };
-
-    const onChangeTime = sinon.spy();
-    const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} timeZone="utc" />);
-    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(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: toUtc(1000),
-      to: toUtc(3000),
-      raw: {
-        from: toUtc(1000),
-        to: toUtc(3000),
-      },
-    };
-    const localRange = {
-      from: dateTime(1000),
-      to: dateTime(3000),
-      raw: {
-        from: dateTime(1000),
-        to: dateTime(3000),
-      },
-    };
-
-    const onChangeTime = sinon.spy();
-    const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} timeZone="browser" />);
-    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);
-  });
-});

+ 0 - 305
public/app/features/explore/TimePicker.tsx

@@ -1,305 +0,0 @@
-import React, { PureComponent, ChangeEvent } from 'react';
-import * as rangeUtil from '@grafana/ui/src/utils/rangeutil';
-import { Input, RawTimeRange, TimeRange, TIME_FORMAT, TimeZone } from '@grafana/ui';
-import { toUtc, isDateTime, dateTime } from '@grafana/ui/src/utils/moment_wrapper';
-
-interface TimePickerProps {
-  isOpen?: boolean;
-  range: TimeRange;
-  timeZone: TimeZone;
-  onChangeTime?: (range: RawTimeRange, scanning?: boolean) => void;
-}
-
-interface TimePickerState {
-  isOpen: boolean;
-  isUtc: boolean;
-  rangeString: string;
-  refreshInterval?: string;
-  initialRange: RawTimeRange;
-
-  // Input-controlled text, keep these in a shape that is human-editable
-  fromRaw: string;
-  toRaw: string;
-}
-
-const getRaw = (range: any, timeZone: TimeZone) => {
-  const rawRange = {
-    from: range.raw.from,
-    to: range.raw.to,
-  };
-
-  if (isDateTime(rawRange.from)) {
-    if (timeZone === 'browser') {
-      rawRange.from = rawRange.from.local();
-    }
-    rawRange.from = rawRange.from.format(TIME_FORMAT);
-  }
-
-  if (isDateTime(rawRange.to)) {
-    if (timeZone === 'browser') {
-      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 rawRange.strings,
- * or on Moment objects.
- * Internally the component needs to keep a string representation in `fromRaw`
- * and `toRaw` for the controlled inputs.
- * When a time is picked, `onChangeTime` is called with the new range that
- * is again based on relative time strings or Moment objects.
- */
-export default class TimePicker extends PureComponent<TimePickerProps, TimePickerState> {
-  dropdownEl: any;
-
-  constructor(props) {
-    super(props);
-
-    const { range, timeZone, isOpen } = props;
-    const rawRange = getRaw(range, timeZone);
-
-    this.state = {
-      isOpen: isOpen,
-      isUtc: timeZone === 'utc',
-      rangeString: rangeUtil.describeTimeRange(range.raw),
-      fromRaw: rawRange.from,
-      toRaw: rawRange.to,
-      initialRange: range.raw,
-      refreshInterval: '',
-    };
-  }
-
-  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 { range } = props;
-    const rawRange = getRaw(range, props.timeZone);
-
-    return {
-      ...state,
-      fromRaw: rawRange.from,
-      toRaw: rawRange.to,
-      initialRange: range.raw,
-      rangeString: rangeUtil.describeTimeRange(range.raw),
-    };
-  }
-
-  move(direction: number, scanning?: boolean): RawTimeRange {
-    const { onChangeTime, range: origRange } = this.props;
-    const range = {
-      from: toUtc(origRange.from),
-      to: toUtc(origRange.to),
-    };
-
-    const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
-    let to, from;
-
-    if (direction === -1) {
-      to = range.to.valueOf() - timespan;
-      from = range.from.valueOf() - timespan;
-    } else if (direction === 1) {
-      to = range.to.valueOf() + timespan;
-      from = range.from.valueOf() + timespan;
-    } else {
-      to = range.to.valueOf();
-      from = range.from.valueOf();
-    }
-
-    const nextTimeRange = {
-      from: this.props.timeZone === 'utc' ? toUtc(from) : dateTime(from),
-      to: this.props.timeZone === 'utc' ? toUtc(to) : dateTime(to),
-    };
-
-    if (onChangeTime) {
-      onChangeTime(nextTimeRange);
-    }
-    return nextTimeRange;
-  }
-
-  handleChangeFrom = (event: ChangeEvent<HTMLInputElement>) => {
-    this.setState({
-      fromRaw: event.target.value,
-    });
-  };
-
-  handleChangeTo = (event: ChangeEvent<HTMLInputElement>) => {
-    this.setState({
-      toRaw: event.target.value,
-    });
-  };
-
-  handleClickApply = () => {
-    const { onChangeTime, timeZone } = this.props;
-    let rawRange;
-
-    this.setState(
-      state => {
-        const { toRaw, fromRaw } = this.state;
-        rawRange = {
-          from: fromRaw,
-          to: toRaw,
-        };
-
-        if (rawRange.from.indexOf('now') === -1) {
-          rawRange.from = timeZone === 'utc' ? toUtc(rawRange.from, TIME_FORMAT) : dateTime(rawRange.from, TIME_FORMAT);
-        }
-
-        if (rawRange.to.indexOf('now') === -1) {
-          rawRange.to = timeZone === 'utc' ? toUtc(rawRange.to, TIME_FORMAT) : dateTime(rawRange.to, TIME_FORMAT);
-        }
-
-        const rangeString = rangeUtil.describeTimeRange(rawRange);
-        return {
-          isOpen: false,
-          rangeString,
-        };
-      },
-      () => {
-        if (onChangeTime) {
-          onChangeTime(rawRange);
-        }
-      }
-    );
-  };
-
-  handleClickLeft = () => this.move(-1);
-  handleClickPicker = () => {
-    this.setState(state => ({
-      isOpen: !state.isOpen,
-    }));
-  };
-  handleClickRight = () => this.move(1);
-  handleClickRefresh = () => {};
-  handleClickRelativeOption = range => {
-    const { onChangeTime } = this.props;
-    const rangeString = rangeUtil.describeTimeRange(range);
-    const rawRange = {
-      from: range.from,
-      to: range.to,
-    };
-    this.setState(
-      {
-        toRaw: rawRange.to,
-        fromRaw: rawRange.from,
-        isOpen: false,
-        rangeString,
-      },
-      () => {
-        if (onChangeTime) {
-          onChangeTime(rawRange);
-        }
-      }
-    );
-  };
-
-  getTimeOptions() {
-    return rangeUtil.getRelativeTimesList({}, this.state.rangeString);
-  }
-
-  dropdownRef = el => {
-    this.dropdownEl = el;
-  };
-
-  renderDropdown() {
-    const { fromRaw, isOpen, toRaw } = this.state;
-    if (!isOpen) {
-      return null;
-    }
-    const timeOptions = this.getTimeOptions();
-    return (
-      <div ref={this.dropdownRef} className="gf-timepicker-dropdown">
-        <div className="popover-box">
-          <div className="popover-box__header">
-            <span className="popover-box__title">Quick ranges</span>
-          </div>
-          <div className="popover-box__body gf-timepicker-relative-section">
-            {Object.keys(timeOptions).map(section => {
-              const group = timeOptions[section];
-              return (
-                <ul key={section}>
-                  {group.map((option: any) => (
-                    <li className={option.active ? 'active' : ''} key={option.display}>
-                      <a onClick={() => this.handleClickRelativeOption(option)}>{option.display}</a>
-                    </li>
-                  ))}
-                </ul>
-              );
-            })}
-          </div>
-        </div>
-
-        <div className="popover-box">
-          <div className="popover-box__header">
-            <span className="popover-box__title">Custom range</span>
-          </div>
-          <div className="popover-box__body gf-timepicker-absolute-section">
-            <label className="small">From:</label>
-            <div className="gf-form-inline">
-              <div className="gf-form max-width-28">
-                <Input
-                  type="text"
-                  className="gf-form-input input-large timepicker-from"
-                  value={fromRaw}
-                  onChange={this.handleChangeFrom}
-                />
-              </div>
-            </div>
-
-            <label className="small">To:</label>
-            <div className="gf-form-inline">
-              <div className="gf-form max-width-28">
-                <Input
-                  type="text"
-                  className="gf-form-input input-large timepicker-to"
-                  value={toRaw}
-                  onChange={this.handleChangeTo}
-                />
-              </div>
-            </div>
-            <div className="gf-form">
-              <button className="btn gf-form-btn btn-secondary" onClick={this.handleClickApply}>
-                Apply
-              </button>
-            </div>
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    const { isUtc, rangeString, refreshInterval } = this.state;
-
-    return (
-      <div className="timepicker">
-        <div className="navbar-buttons">
-          <button className="btn navbar-button navbar-button--tight timepicker-left" onClick={this.handleClickLeft}>
-            <i className="fa fa-chevron-left" />
-          </button>
-          <button className="btn navbar-button gf-timepicker-nav-btn" onClick={this.handleClickPicker}>
-            <i className="fa fa-clock-o" />
-            <span className="timepicker-rangestring">{rangeString}</span>
-            {isUtc ? <span className="gf-timepicker-utc">UTC</span> : null}
-            {refreshInterval ? <span className="text-warning">&nbsp; Refresh every {refreshInterval}</span> : null}
-          </button>
-          <button className="btn navbar-button navbar-button--tight timepicker-right" onClick={this.handleClickRight}>
-            <i className="fa fa-chevron-right" />
-          </button>
-        </div>
-        {this.renderDropdown()}
-      </div>
-    );
-  }
-}

+ 1 - 16
public/app/features/explore/state/actionTypes.ts

@@ -16,15 +16,7 @@ import {
   LoadingState,
   AbsoluteTimeRange,
 } from '@grafana/ui/src/types';
-import {
-  ExploreId,
-  ExploreItemState,
-  HistoryItem,
-  RangeScanner,
-  ExploreUIState,
-  ExploreMode,
-  QueryOptions,
-} from 'app/types/explore';
+import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode, QueryOptions } from 'app/types/explore';
 import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
 import TableModel from 'app/core/table_model';
 
@@ -171,12 +163,6 @@ export interface RemoveQueryRowPayload {
 
 export interface ScanStartPayload {
   exploreId: ExploreId;
-  scanner: RangeScanner;
-}
-
-export interface ScanRangePayload {
-  exploreId: ExploreId;
-  range: RawTimeRange;
 }
 
 export interface ScanStopPayload {
@@ -397,7 +383,6 @@ export const runQueriesAction = actionCreatorFactory<RunQueriesPayload>('explore
  * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range
  */
 export const scanStartAction = actionCreatorFactory<ScanStartPayload>('explore/SCAN_START').create();
-export const scanRangeAction = actionCreatorFactory<ScanRangePayload>('explore/SCAN_RANGE').create();
 
 /**
  * Stop any scanning for more results.

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

@@ -26,7 +26,7 @@ import {
   LogsDedupStrategy,
   AbsoluteTimeRange,
 } from '@grafana/ui';
-import { ExploreId, RangeScanner, ExploreUIState, QueryTransaction, ExploreMode } from 'app/types/explore';
+import { ExploreId, ExploreUIState, QueryTransaction, ExploreMode } from 'app/types/explore';
 import {
   updateDatasourceInstanceAction,
   changeQueryAction,
@@ -58,7 +58,6 @@ import {
   loadExploreDatasources,
   changeModeAction,
   scanStopAction,
-  scanRangeAction,
   runQueriesAction,
   stateSaveAction,
   updateTimeRangeAction,
@@ -66,6 +65,7 @@ import {
 import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
 import { getTimeZone } from 'app/features/profile/state/selectors';
 import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker';
+import { getShiftedTimeRange } from 'app/core/utils/timePicker';
 
 /**
  * Updates UI state and save it to the URL
@@ -413,14 +413,15 @@ export function runQueries(exploreId: ExploreId): ThunkResult<void> {
  * @param exploreId Explore area
  * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range
  */
-export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkResult<void> {
-  return dispatch => {
+export function scanStart(exploreId: ExploreId): ThunkResult<void> {
+  return (dispatch, getState) => {
     // Register the scanner
-    dispatch(scanStartAction({ exploreId, scanner }));
+    dispatch(scanStartAction({ exploreId }));
     // Scanning must trigger query run, and return the new range
-    const range = scanner();
+    const range = getShiftedTimeRange(-1, getState().explore[exploreId].range);
     // Set the new range to be displayed
-    dispatch(scanRangeAction({ exploreId, range }));
+    dispatch(updateTimeRangeAction({ exploreId, absoluteRange: range }));
+    dispatch(runQueriesAction({ exploreId }));
   };
 }
 

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

@@ -1,11 +1,12 @@
 import { mockExploreState } from 'test/mocks/mockExploreState';
-import { epicTester } from 'test/core/redux/epicTester';
+import { epicTester, MOCKED_ABSOLUTE_RANGE } from 'test/core/redux/epicTester';
 import {
   processQueryResultsAction,
   resetQueryErrorAction,
   querySuccessAction,
   scanStopAction,
-  scanRangeAction,
+  updateTimeRangeAction,
+  runQueriesAction,
 } from '../actionTypes';
 import { SeriesData, LoadingState } from '@grafana/ui';
 import { processQueryResultsEpic } from './processQueryResultsEpic';
@@ -81,7 +82,7 @@ describe('processQueryResultsEpic', () => {
 
         describe('and we do not have a result', () => {
           it('then correct actions are dispatched', () => {
-            const { datasourceId, exploreId, state, scanner } = mockExploreState({ scanning: true });
+            const { datasourceId, exploreId, state } = mockExploreState({ scanning: true });
             const { latency, loadingState } = testContext();
             const graphResult = [];
             const tableResult = new TableModel();
@@ -94,7 +95,8 @@ describe('processQueryResultsEpic', () => {
               .thenResultingActionsEqual(
                 resetQueryErrorAction({ exploreId, refIds: [] }),
                 querySuccessAction({ exploreId, loadingState, graphResult, tableResult, logsResult, latency }),
-                scanRangeAction({ exploreId, range: scanner() })
+                updateTimeRangeAction({ exploreId, absoluteRange: MOCKED_ABSOLUTE_RANGE }),
+                runQueriesAction({ exploreId })
               );
           });
         });

+ 11 - 5
public/app/features/explore/state/epics/processQueryResultsEpic.ts

@@ -11,17 +11,22 @@ import {
   processQueryResultsAction,
   ProcessQueryResultsPayload,
   querySuccessAction,
-  scanRangeAction,
   resetQueryErrorAction,
   scanStopAction,
+  updateTimeRangeAction,
+  runQueriesAction,
 } from '../actionTypes';
 import { ResultProcessor } from '../../utils/ResultProcessor';
 
-export const processQueryResultsEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState> = (action$, state$) => {
+export const processQueryResultsEpic: Epic<ActionOf<any>, ActionOf<any>, StoreState> = (
+  action$,
+  state$,
+  { getTimeZone, getShiftedTimeRange }
+) => {
   return action$.ofType(processQueryResultsAction.type).pipe(
     mergeMap((action: ActionOf<ProcessQueryResultsPayload>) => {
       const { exploreId, datasourceId, latency, loadingState, series, delta } = action.payload;
-      const { datasourceInstance, scanning, scanner, eventBridge } = state$.value.explore[exploreId];
+      const { datasourceInstance, scanning, eventBridge } = state$.value.explore[exploreId];
 
       // If datasource already changed, results do not matter
       if (datasourceInstance.meta.id !== datasourceId) {
@@ -62,8 +67,9 @@ export const processQueryResultsEpic: Epic<ActionOf<any>, ActionOf<any>, StoreSt
       // Keep scanning for results if this was the last scanning transaction
       if (scanning) {
         if (_.size(result) === 0) {
-          const range = scanner();
-          actions.push(scanRangeAction({ exploreId, range }));
+          const range = getShiftedTimeRange(-1, state$.value.explore[exploreId].range, getTimeZone(state$.value.user));
+          actions.push(updateTimeRangeAction({ exploreId, absoluteRange: range }));
+          actions.push(runQueriesAction({ exploreId }));
         } else {
           // We can stop scanning if we have a result
           actions.push(scanStopAction({ exploreId }));

+ 2 - 15
public/app/features/explore/state/reducers.test.ts

@@ -5,14 +5,7 @@ import {
   makeInitialUpdateState,
   initialExploreState,
 } from './reducers';
-import {
-  ExploreId,
-  ExploreItemState,
-  ExploreUrlState,
-  ExploreState,
-  RangeScanner,
-  ExploreMode,
-} from 'app/types/explore';
+import { ExploreId, ExploreItemState, ExploreUrlState, ExploreState, ExploreMode } from 'app/types/explore';
 import { reducerTester } from 'test/core/redux/reducerTester';
 import {
   scanStartAction,
@@ -36,28 +29,23 @@ import { DataSourceApi, DataQuery, LogsModel, LogsDedupStrategy, LoadingState }
 describe('Explore item reducer', () => {
   describe('scanning', () => {
     it('should start scanning', () => {
-      const scanner = jest.fn();
       const initalState = {
         ...makeExploreItemState(),
         scanning: false,
-        scanner: undefined as RangeScanner,
       };
 
       reducerTester()
         .givenReducer(itemReducer as Reducer<ExploreItemState, ActionOf<any>>, initalState)
-        .whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left, scanner }))
+        .whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left }))
         .thenStateShouldEqual({
           ...makeExploreItemState(),
           scanning: true,
-          scanner,
         });
     });
     it('should stop scanning', () => {
-      const scanner = jest.fn();
       const initalState = {
         ...makeExploreItemState(),
         scanning: true,
-        scanner,
         scanRange: {},
       };
 
@@ -67,7 +55,6 @@ describe('Explore item reducer', () => {
         .thenStateShouldEqual({
           ...makeExploreItemState(),
           scanning: false,
-          scanner: undefined,
           scanRange: undefined,
         });
     });

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

@@ -24,7 +24,6 @@ import {
   queryFailureAction,
   setUrlReplacedAction,
   querySuccessAction,
-  scanRangeAction,
   scanStopAction,
   resetQueryErrorAction,
   queryStartAction,
@@ -404,16 +403,10 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       };
     },
   })
-  .addMapper({
-    filter: scanRangeAction,
-    mapper: (state, action): ExploreItemState => {
-      return { ...state, scanRange: action.payload.range };
-    },
-  })
   .addMapper({
     filter: scanStartAction,
     mapper: (state, action): ExploreItemState => {
-      return { ...state, scanning: true, scanner: action.payload.scanner };
+      return { ...state, scanning: true };
     },
   })
   .addMapper({
@@ -423,7 +416,6 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         ...state,
         scanning: false,
         scanRange: undefined,
-        scanner: undefined,
         update: makeInitialUpdateState(),
       };
     },

+ 4 - 0
public/app/store/configureStore.ts

@@ -36,6 +36,7 @@ import {
   DateTime,
   toUtc,
   dateTime,
+  AbsoluteTimeRange,
 } from '@grafana/ui';
 import { Observable } from 'rxjs';
 import { getQueryResponse } from 'app/core/utils/explore';
@@ -46,6 +47,7 @@ import { TimeSrv, getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
 import { UserState } from 'app/types/user';
 import { getTimeRange } from 'app/core/utils/explore';
 import { getTimeZone } from 'app/features/profile/state/selectors';
+import { getShiftedTimeRange } from 'app/core/utils/timePicker';
 
 const rootReducers = {
   ...sharedReducers,
@@ -87,6 +89,7 @@ export interface EpicDependencies {
   getTimeZone: (state: UserState) => TimeZone;
   toUtc: (input?: DateTimeInput, formatInput?: FormatInput) => DateTime;
   dateTime: (input?: DateTimeInput, formatInput?: FormatInput) => DateTime;
+  getShiftedTimeRange: (direction: number, origRange: TimeRange, timeZone: TimeZone) => AbsoluteTimeRange;
 }
 
 const dependencies: EpicDependencies = {
@@ -96,6 +99,7 @@ const dependencies: EpicDependencies = {
   getTimeZone,
   toUtc,
   dateTime,
+  getShiftedTimeRange,
 };
 
 const epicMiddleware = createEpicMiddleware({ dependencies });

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

@@ -192,10 +192,6 @@ export interface ExploreItemState {
   range: TimeRange;
 
   absoluteRange: AbsoluteTimeRange;
-  /**
-   * Scanner function that calculates a new range, triggers a query run, and returns the new range.
-   */
-  scanner?: RangeScanner;
   /**
    * True if scanning for more results is active.
    */
@@ -334,8 +330,6 @@ export interface QueryTransaction {
   scanning?: boolean;
 }
 
-export type RangeScanner = () => RawTimeRange;
-
 export interface TextMatch {
   text: string;
   start: number;

+ 4 - 0
public/sass/pages/_explore.scss

@@ -98,6 +98,10 @@
   padding: 10px 2px;
 }
 
+.explore-toolbar-content-item.timepicker {
+  z-index: $zindex-timepicker-popover;
+}
+
 .explore-toolbar-content-item:first-child {
   padding-left: $dashboard-padding;
   margin-right: auto;

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

@@ -17,6 +17,8 @@ import { EpicDependencies } from 'app/store/configureStore';
 import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
 import { DEFAULT_RANGE } from 'app/core/utils/explore';
 
+export const MOCKED_ABSOLUTE_RANGE = { from: 1, to: 2 };
+
 export const epicTester = (
   epic: Epic<ActionOf<any>, ActionOf<any>, StoreState, EpicDependencies>,
   state?: Partial<StoreState>,
@@ -48,6 +50,8 @@ export const epicTester = (
 
   const getTimeRange = jest.fn().mockReturnValue(DEFAULT_RANGE);
 
+  const getShiftedTimeRange = jest.fn().mockReturnValue(MOCKED_ABSOLUTE_RANGE);
+
   const getTimeZone = jest.fn().mockReturnValue(DefaultTimeZone);
 
   const toUtc = jest.fn().mockReturnValue(null);
@@ -61,6 +65,7 @@ export const epicTester = (
     getTimeZone,
     toUtc,
     dateTime,
+    getShiftedTimeRange,
   };
 
   const theDependencies: EpicDependencies = { ...defaultDependencies, ...dependencies };