fix(browser history): fixes and enhancements to browser history, it now works properly again AND it can restore previous time ranges in dashboards, closes #7259

This commit is contained in:
Torkel Ödegaard 2017-01-13 17:37:53 +01:00
parent 60a2041065
commit 49fe74228b
14 changed files with 336 additions and 310 deletions

View File

@ -5,6 +5,7 @@
* **Templating**: Make $__interval and $__interval_ms global built in variables that can be used in by any datasource (in panel queries), closes [#7190](https://github.com/grafana/grafana/issues/7190), closes [#6582](https://github.com/grafana/grafana/issues/6582)
* **S3 Image Store**: External s3 image store (used in alert notifications) now support AWS IAM Roles, closes [#6985](https://github.com/grafana/grafana/issues/6985), [#7058](https://github.com/grafana/grafana/issues/7058) thx [@mtanda](https://github.com/mtanda)
* **Optimzation**: Never issue refresh event when Grafana tab is not visible [#7218](https://github.com/grafana/grafana/issues/7218), thx [@mtanda](https://github.com/mtanda)
* **Browser History**: Browser back/forward now works time ranges / zoom, [#7259](https://github.com/grafana/grafana/issues/7259)
# 4.1.1 (2017-01-11)

View File

@ -22,7 +22,7 @@ function (angular, _, coreModule) {
$timeout.cancel(promise);
};
this.cancel_all = function() {
this.cancelAll = function() {
_.each(timers, function(t) {
$timeout.cancel(t);
});

View File

@ -9,7 +9,7 @@ define([
'./shareSnapshotCtrl',
'./dashboard_srv',
'./viewStateSrv',
'./timeSrv',
'./time_srv',
'./unsavedChangesSrv',
'./timepicker/timepicker',
'./graphiteImportCtrl',

View File

@ -0,0 +1,110 @@
import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
import helpers from 'test/specs/helpers';
import _ from 'lodash';
import TimeSrv from '../time_srv';
import moment from 'moment';
describe('timeSrv', function() {
var ctx = new helpers.ServiceTestContext();
var _dashboard: any = {
time: {from: 'now-6h', to: 'now'},
};
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(ctx.createService('timeSrv'));
beforeEach(function() {
ctx.service.init(_dashboard);
});
describe('timeRange', function() {
it('should return unparsed when parse is false', function() {
ctx.service.setTime({from: 'now', to: 'now-1h' });
var time = ctx.service.timeRange();
expect(time.raw.from).to.be('now');
expect(time.raw.to).to.be('now-1h');
});
it('should return parsed when parse is true', function() {
ctx.service.setTime({from: 'now', to: 'now-1h' });
var time = ctx.service.timeRange();
expect(moment.isMoment(time.from)).to.be(true);
expect(moment.isMoment(time.to)).to.be(true);
});
});
describe('init time from url', function() {
it('should handle relative times', function() {
ctx.$location.search({from: 'now-2d', to: 'now'});
ctx.service.init(_dashboard);
var time = ctx.service.timeRange();
expect(time.raw.from).to.be('now-2d');
expect(time.raw.to).to.be('now');
});
it('should handle formated dates', function() {
ctx.$location.search({from: '20140410T052010', to: '20140520T031022'});
ctx.service.init(_dashboard);
var time = ctx.service.timeRange(true);
expect(time.from.valueOf()).to.equal(new Date("2014-04-10T05:20:10Z").getTime());
expect(time.to.valueOf()).to.equal(new Date("2014-05-20T03:10:22Z").getTime());
});
it('should handle formated dates without time', function() {
ctx.$location.search({from: '20140410', to: '20140520'});
ctx.service.init(_dashboard);
var time = ctx.service.timeRange(true);
expect(time.from.valueOf()).to.equal(new Date("2014-04-10T00:00:00Z").getTime());
expect(time.to.valueOf()).to.equal(new Date("2014-05-20T00:00:00Z").getTime());
});
it('should handle epochs', function() {
ctx.$location.search({from: '1410337646373', to: '1410337665699'});
ctx.service.init(_dashboard);
var time = ctx.service.timeRange(true);
expect(time.from.valueOf()).to.equal(1410337646373);
expect(time.to.valueOf()).to.equal(1410337665699);
});
it('should handle bad dates', function() {
ctx.$location.search({from: '20151126T00010%3C%2Fp%3E%3Cspan%20class', to: 'now'});
_dashboard.time.from = 'now-6h';
ctx.service.init(_dashboard);
expect(ctx.service.time.from).to.equal('now-6h');
expect(ctx.service.time.to).to.equal('now');
});
});
describe('setTime', function() {
it('should return disable refresh if refresh is disabled for any range', function() {
_dashboard.refresh = false;
ctx.service.setTime({from: '2011-01-01', to: '2015-01-01' });
expect(_dashboard.refresh).to.be(false);
});
it('should restore refresh for absolute time range', function() {
_dashboard.refresh = '30s';
ctx.service.setTime({from: '2011-01-01', to: '2015-01-01' });
expect(_dashboard.refresh).to.be('30s');
});
it('should restore refresh after relative time range is set', function() {
_dashboard.refresh = '10s';
ctx.service.setTime({from: moment([2011,1,1]), to: moment([2015,1,1])});
expect(_dashboard.refresh).to.be(false);
ctx.service.setTime({from: '2011-01-01', to: 'now' });
expect(_dashboard.refresh).to.be('10s');
});
it('should keep refresh after relative time range is changed and now delay exists', function() {
_dashboard.refresh = '10s';
ctx.service.setTime({from: 'now-1h', to: 'now-10s' });
expect(_dashboard.refresh).to.be('10s');
});
});
});

View File

@ -1,170 +0,0 @@
define([
'angular',
'lodash',
'moment',
'app/core/config',
'app/core/utils/kbn',
'app/core/utils/datemath'
], function (angular, _, moment, config, kbn, dateMath) {
'use strict';
var module = angular.module('grafana.services');
module.service('timeSrv', function($rootScope, $timeout, $routeParams, timer, contextSrv) {
var self = this;
// default time
this.time = {from: '6h', to: 'now'};
$rootScope.$on('zoom-out', function(e, factor) { self.zoomOut(factor); });
this.init = function(dashboard) {
timer.cancel_all();
this.dashboard = dashboard;
this.time = dashboard.time;
this.refresh = dashboard.refresh;
this._initTimeFromUrl();
this._parseTime();
if(this.refresh) {
this.setAutoRefresh(this.refresh);
}
};
this._parseTime = function() {
// when absolute time is saved in json it is turned to a string
if (_.isString(this.time.from) && this.time.from.indexOf('Z') >= 0) {
this.time.from = moment(this.time.from).utc();
}
if (_.isString(this.time.to) && this.time.to.indexOf('Z') >= 0) {
this.time.to = moment(this.time.to).utc();
}
};
this._parseUrlParam = function(value) {
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');
}
if (!isNaN(value)) {
var epoch = parseInt(value);
return moment.utc(epoch);
}
return null;
};
this._initTimeFromUrl = function() {
if ($routeParams.from) {
this.time.from = this._parseUrlParam($routeParams.from) || this.time.from;
}
if ($routeParams.to) {
this.time.to = this._parseUrlParam($routeParams.to) || this.time.to;
}
if ($routeParams.refresh) {
this.refresh = $routeParams.refresh || this.refresh;
}
};
this.setAutoRefresh = function (interval) {
this.dashboard.refresh = interval;
if (interval) {
var interval_ms = kbn.interval_to_ms(interval);
$timeout(function () {
self.start_scheduled_refresh(interval_ms);
self.refreshDashboard();
}, interval_ms);
} else {
this.cancel_scheduled_refresh();
}
};
this.refreshDashboard = function() {
$rootScope.$broadcast('refresh');
};
this.start_scheduled_refresh = function (after_ms) {
self.cancel_scheduled_refresh();
self.refresh_timer = timer.register($timeout(function () {
self.start_scheduled_refresh(after_ms);
if (contextSrv.isGrafanaVisible()) {
self.refreshDashboard();
}
}, after_ms));
};
this.cancel_scheduled_refresh = function () {
timer.cancel(this.refresh_timer);
};
this.setTime = function(time, enableRefresh) {
_.extend(this.time, time);
// disable refresh if zoom in or zoom out
if (!enableRefresh && moment.isMoment(time.to)) {
this.old_refresh = this.dashboard.refresh || this.old_refresh;
this.setAutoRefresh(false);
}
else if (this.old_refresh && this.old_refresh !== this.dashboard.refresh) {
this.setAutoRefresh(this.old_refresh);
this.old_refresh = null;
}
$rootScope.appEvent('time-range-changed', this.time);
$timeout(this.refreshDashboard, 0);
};
this.timeRangeForUrl = function() {
var range = this.timeRange().raw;
if (moment.isMoment(range.from)) { range.from = range.from.valueOf(); }
if (moment.isMoment(range.to)) { range.to = range.to.valueOf(); }
return range;
};
this.timeRange = function() {
// make copies if they are moment (do not want to return out internal moment, because they are mutable!)
var range = {
from: moment.isMoment(this.time.from) ? moment(this.time.from) : this.time.from,
to: moment.isMoment(this.time.to) ? moment(this.time.to) : this.time.to,
};
range = {
from: dateMath.parse(range.from, false),
to: dateMath.parse(range.to, true),
raw: range
};
return range;
};
this.zoomOut = function(factor) {
var range = this.timeRange();
var timespan = (range.to.valueOf() - range.from.valueOf());
var center = range.to.valueOf() - timespan/2;
var to = (center + (timespan*factor)/2);
var from = (center - (timespan*factor)/2);
if (to > Date.now() && range.to <= Date.now()) {
var offset = to - Date.now();
from = from - offset;
to = Date.now();
}
this.setTime({from: moment.utc(from), to: moment.utc(to) });
};
});
});

View File

@ -0,0 +1,204 @@
///<reference path="../../headers/common.d.ts" />
import config from 'app/core/config';
import angular from 'angular';
import moment from 'moment';
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import kbn from 'app/core/utils/kbn';
import * as dateMath from 'app/core/utils/datemath';
class TimeSrv {
time: any;
refreshTimer: any;
refresh: boolean;
oldRefresh: boolean;
dashboard: any;
timeAtLoad: any;
/** @ngInject **/
constructor(private $rootScope, private $timeout, private $location, private timer, private contextSrv) {
// default time
this.time = {from: '6h', to: 'now'};
$rootScope.$on('zoom-out', this.zoomOut.bind(this));
$rootScope.$on('$routeUpdate', this.routeUpdated.bind(this));
}
init(dashboard) {
this.timer.cancelAll();
this.dashboard = dashboard;
this.time = dashboard.time;
this.refresh = dashboard.refresh;
this.initTimeFromUrl();
this.parseTime();
// remember time at load so we can go back to it
this.timeAtLoad = _.cloneDeep(this.time);
if (this.refresh) {
this.setAutoRefresh(this.refresh);
}
}
private parseTime() {
// when absolute time is saved in json it is turned to a string
if (_.isString(this.time.from) && this.time.from.indexOf('Z') >= 0) {
this.time.from = moment(this.time.from).utc();
}
if (_.isString(this.time.to) && this.time.to.indexOf('Z') >= 0) {
this.time.to = moment(this.time.to).utc();
}
};
private parseUrlParam(value) {
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');
}
if (!isNaN(value)) {
var epoch = parseInt(value);
return moment.utc(epoch);
}
return null;
}
private initTimeFromUrl() {
var params = this.$location.search();
if (params.from) {
this.time.from = this.parseUrlParam(params.from) || this.time.from;
}
if (params.to) {
this.time.to = this.parseUrlParam(params.to) || this.time.to;
}
if (params.refresh) {
this.refresh = params.refresh || this.refresh;
}
};
private routeUpdated() {
var params = this.$location.search();
var urlRange = this.timeRangeForUrl();
// check if url has time range
if (params.from && params.to) {
// is it different from what our current time range?
if (params.from !== urlRange.from || params.to !== urlRange.to) {
// issue update
this.initTimeFromUrl();
this.setTime(this.time, true);
}
} else {
this.setTime(this.timeAtLoad, true);
}
}
setAutoRefresh(interval) {
this.dashboard.refresh = interval;
if (interval) {
var intervalMs = kbn.interval_to_ms(interval);
this.$timeout(() => {
this.startNextRefreshTimer(intervalMs);
this.refreshDashboard();
}, intervalMs);
} else {
this.cancelNextRefresh();
}
}
refreshDashboard() {
this.$rootScope.$broadcast('refresh');
}
private startNextRefreshTimer(afterMs) {
this.cancelNextRefresh();
this.refreshTimer = this.timer.register(this.$timeout(() => {
this.startNextRefreshTimer(afterMs);
if (this.contextSrv.isGrafanaVisible()) {
this.refreshDashboard();
}
}, afterMs));
}
private cancelNextRefresh() {
this.timer.cancel(this.refreshTimer);
};
setTime(time, fromRouteUpdate?) {
_.extend(this.time, time);
// disable refresh if zoom in or zoom out
if (moment.isMoment(time.to)) {
this.oldRefresh = this.dashboard.refresh || this.oldRefresh;
this.setAutoRefresh(false);
} else if (this.oldRefresh && this.oldRefresh !== this.dashboard.refresh) {
this.setAutoRefresh(this.oldRefresh);
this.oldRefresh = null;
}
// update url
if (fromRouteUpdate !== true) {
var urlRange = this.timeRangeForUrl();
var urlParams = this.$location.search();
urlParams.from = urlRange.from;
urlParams.to = urlRange.to;
this.$location.search(urlParams);
}
this.$rootScope.appEvent('time-range-changed', this.time);
this.$timeout(this.refreshDashboard.bind(this), 0);
}
timeRangeForUrl() {
var range = this.timeRange().raw;
if (moment.isMoment(range.from)) { range.from = range.from.valueOf(); }
if (moment.isMoment(range.to)) { range.to = range.to.valueOf(); }
return range;
}
timeRange() {
// make copies if they are moment (do not want to return out internal moment, because they are mutable!)
var raw = {
from: moment.isMoment(this.time.from) ? moment(this.time.from) : this.time.from,
to: moment.isMoment(this.time.to) ? moment(this.time.to) : this.time.to,
};
return {
from: dateMath.parse(raw.from, false),
to: dateMath.parse(raw.to, true),
raw: raw
};
}
zoomOut(e, factor) {
var range = this.timeRange();
var timespan = (range.to.valueOf() - range.from.valueOf());
var center = range.to.valueOf() - timespan/2;
var to = (center + (timespan*factor)/2);
var from = (center - (timespan*factor)/2);
if (to > Date.now() && range.to <= Date.now()) {
var offset = to - Date.now();
from = from - offset;
to = Date.now();
}
this.setTime({from: moment.utc(from), to: moment.utc(to)});
}
}
coreModule.service('timeSrv', TimeSrv);

View File

@ -97,8 +97,7 @@ export class TimePickerCtrl {
from = range.from.valueOf();
}
this.timeSrv.setTime({from: moment.utc(from), to: moment.utc(to) });
this.timeSrv.setTime({from: moment.utc(from), to: moment.utc(to)});
}
openDropdown() {
@ -126,7 +125,7 @@ export class TimePickerCtrl {
this.timeSrv.setAutoRefresh(this.refresh.value);
}
this.timeSrv.setTime(this.timeRaw, true);
this.timeSrv.setTime(this.timeRaw);
this.$rootScope.appEvent('hide-dash-editor');
}

View File

@ -8,7 +8,7 @@ function (angular, _, $) {
var module = angular.module('grafana.services');
module.factory('dashboardViewStateSrv', function($location, $timeout, templateSrv, contextSrv, timeSrv) {
module.factory('dashboardViewStateSrv', function($location, $timeout) {
// represents the transient view state
// like fullscreen panel & edit
@ -25,15 +25,6 @@ function (angular, _, $) {
}
};
// update url on time range change
$scope.onAppEvent('time-range-changed', function() {
var urlParams = $location.search();
var urlRange = timeSrv.timeRangeForUrl();
urlParams.from = urlRange.from;
urlParams.to = urlRange.to;
$location.search(urlParams);
});
$scope.onAppEvent('$routeUpdate', function() {
var urlState = self.getQueryStringState();
if (self.needsSync(urlState)) {
@ -82,7 +73,7 @@ function (angular, _, $) {
return urlState;
};
DashboardViewState.prototype.update = function(state) {
DashboardViewState.prototype.update = function(state, fromRouteUpdated) {
// implement toggle logic
if (state.toggle) {
delete state.toggle;
@ -113,7 +104,12 @@ function (angular, _, $) {
delete this.state.tab;
}
$location.search(this.serializeToUrl());
// do not update url params if we are here
// from routeUpdated event
if (fromRouteUpdated !== true) {
$location.search(this.serializeToUrl());
}
this.syncState();
};

View File

@ -8,6 +8,9 @@ describe('templateSrv', function() {
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(angularMocks.module($provide => {
$provide.value('timeSrv', {});
}));
beforeEach(angularMocks.inject(function(variableSrv, templateSrv) {
_templateSrv = templateSrv;

View File

@ -11,7 +11,7 @@ describe('ElasticDatasource', function() {
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(ctx.providePhase(['templateSrv', 'backendSrv']));
beforeEach(ctx.providePhase(['templateSrv', 'backendSrv', 'timeSrv']));
beforeEach(angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) {
ctx.$q = $q;

View File

@ -9,6 +9,8 @@ describe('PrometheusDatasource', function() {
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(ctx.providePhase(['timeSrv']));
beforeEach(angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) {
ctx.$q = $q;
ctx.$httpBackend = $httpBackend;

View File

@ -92,7 +92,6 @@ define([
self.timeSrv = new TimeSrvStub();
self.datasourceSrv = {};
self.backendSrv = {};
self.$location = {};
self.$routeParams = {};
this.providePhase = function(mocks) {
@ -104,10 +103,11 @@ define([
};
this.createService = function(name) {
return inject(function($q, $rootScope, $httpBackend, $injector) {
return inject(function($q, $rootScope, $httpBackend, $injector, $location) {
self.$q = $q;
self.$rootScope = $rootScope;
self.$httpBackend = $httpBackend;
self.$location = $location;
self.$rootScope.onAppEvent = function() {};
self.$rootScope.appEvent = function() {};

View File

@ -1,5 +1,6 @@
define([
'lodash',
'app/features/dashboard/all',
'app/features/panellinks/linkSrv'
], function(_) {
'use strict';

View File

@ -1,120 +0,0 @@
define([
'test/mocks/dashboard-mock',
'test/specs/helpers',
'lodash',
'moment',
'app/core/services/timer',
'app/features/dashboard/timeSrv'
], function(dashboardMock, helpers, _, moment) {
'use strict';
describe('timeSrv', function() {
var ctx = new helpers.ServiceTestContext();
var _dashboard;
beforeEach(module('grafana.core'));
beforeEach(module('grafana.services'));
beforeEach(ctx.providePhase(['$routeParams']));
beforeEach(ctx.createService('timeSrv'));
beforeEach(function() {
_dashboard = dashboardMock.create();
ctx.service.init(_dashboard);
});
describe('timeRange', function() {
it('should return unparsed when parse is false', function() {
ctx.service.setTime({from: 'now', to: 'now-1h' });
var time = ctx.service.timeRange();
expect(time.raw.from).to.be('now');
expect(time.raw.to).to.be('now-1h');
});
it('should return parsed when parse is true', function() {
ctx.service.setTime({from: 'now', to: 'now-1h' });
var time = ctx.service.timeRange();
expect(moment.isMoment(time.from)).to.be(true);
expect(moment.isMoment(time.to)).to.be(true);
});
});
describe('init time from url', function() {
it('should handle relative times', function() {
ctx.$routeParams.from = 'now-2d';
ctx.$routeParams.to = 'now';
ctx.service.init(_dashboard);
var time = ctx.service.timeRange();
expect(time.raw.from).to.be('now-2d');
expect(time.raw.to).to.be('now');
});
it('should handle formated dates', function() {
ctx.$routeParams.from = '20140410T052010';
ctx.$routeParams.to = '20140520T031022';
ctx.service.init(_dashboard);
var time = ctx.service.timeRange(true);
expect(time.from.valueOf()).to.equal(new Date("2014-04-10T05:20:10Z").getTime());
expect(time.to.valueOf()).to.equal(new Date("2014-05-20T03:10:22Z").getTime());
});
it('should handle formated dates without time', function() {
ctx.$routeParams.from = '20140410';
ctx.$routeParams.to = '20140520';
ctx.service.init(_dashboard);
var time = ctx.service.timeRange(true);
expect(time.from.valueOf()).to.equal(new Date("2014-04-10T00:00:00Z").getTime());
expect(time.to.valueOf()).to.equal(new Date("2014-05-20T00:00:00Z").getTime());
});
it('should handle epochs', function() {
ctx.$routeParams.from = '1410337646373';
ctx.$routeParams.to = '1410337665699';
ctx.service.init(_dashboard);
var time = ctx.service.timeRange(true);
expect(time.from.valueOf()).to.equal(1410337646373);
expect(time.to.valueOf()).to.equal(1410337665699);
});
it('should handle bad dates', function() {
ctx.$routeParams.from = '20151126T00010%3C%2Fp%3E%3Cspan%20class';
ctx.$routeParams.to = 'now';
_dashboard.time.from = 'now-6h';
ctx.service.init(_dashboard);
expect(ctx.service.time.from).to.equal('now-6h');
expect(ctx.service.time.to).to.equal('now');
});
});
describe('setTime', function() {
it('should return disable refresh if refresh is disabled for any range', function() {
_dashboard.refresh = false;
ctx.service.setTime({from: '2011-01-01', to: '2015-01-01' });
expect(_dashboard.refresh).to.be(false);
});
it('should restore refresh for absolute time range', function() {
_dashboard.refresh = '30s';
ctx.service.setTime({from: '2011-01-01', to: '2015-01-01' });
expect(_dashboard.refresh).to.be('30s');
});
it('should restore refresh after relative time range is set', function() {
_dashboard.refresh = '10s';
ctx.service.setTime({from: moment([2011,1,1]), to: moment([2015,1,1])});
expect(_dashboard.refresh).to.be(false);
ctx.service.setTime({from: '2011-01-01', to: 'now' });
expect(_dashboard.refresh).to.be('10s');
});
it('should keep refresh after relative time range is changed and now delay exists', function() {
_dashboard.refresh = '10s';
ctx.service.setTime({from: 'now-1h', to: 'now-10s' });
expect(_dashboard.refresh).to.be('10s');
});
});
});
});