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

feat(testdata): worked on testdata app

Torkel Ödegaard 9 éve
szülő
commit
34f15d92d0
30 módosított fájl, 512 hozzáadás és 169 törlés
  1. 1 6
      pkg/api/dtos/models.go
  2. 1 1
      pkg/api/index.go
  3. 30 20
      pkg/api/metrics.go
  4. 6 1
      pkg/plugins/frontend_plugin.go
  5. 4 4
      pkg/services/alerting/conditions/query.go
  6. 1 0
      pkg/services/alerting/init/init.go
  7. 11 10
      pkg/tsdb/models.go
  8. 54 0
      pkg/tsdb/testdata/testdata.go
  9. 10 1
      pkg/tsdb/time_range.go
  10. 17 0
      pkg/tsdb/time_range_test.go
  11. 3 0
      public/app/core/time_series2.ts
  12. 8 2
      public/app/core/utils/kbn.js
  13. 16 3
      public/app/features/panel/metrics_panel_ctrl.ts
  14. 2 2
      public/app/features/templating/interval_variable.ts
  15. 5 0
      public/app/plugins/app/testdata/dashboards/graph_last_1h.json
  16. 45 0
      public/app/plugins/app/testdata/datasource/datasource.ts
  17. 22 0
      public/app/plugins/app/testdata/datasource/module.ts
  18. 19 0
      public/app/plugins/app/testdata/datasource/plugin.json
  19. 24 0
      public/app/plugins/app/testdata/datasource/query_ctrl.ts
  20. 36 0
      public/app/plugins/app/testdata/module.ts
  21. 22 0
      public/app/plugins/app/testdata/partials/query.editor.html
  22. 27 0
      public/app/plugins/app/testdata/plugin.json
  23. 32 36
      public/app/plugins/panel/graph/data_processor.ts
  24. 7 7
      public/app/plugins/panel/graph/graph_tooltip.js
  25. 11 5
      public/app/plugins/panel/graph/module.ts
  26. 23 33
      public/app/plugins/panel/graph/specs/graph_ctrl_specs.ts
  27. 6 3
      public/app/plugins/panel/graph/template.ts
  28. 1 1
      public/app/plugins/panel/pluginlist/plugin.json
  29. 32 0
      public/test/core/time_series_specs.js
  30. 36 34
      public/test/core/utils/kbn_specs.js

+ 1 - 6
pkg/api/dtos/models.go

@@ -97,12 +97,7 @@ func (slice DataSourceList) Swap(i, j int) {
 }
 
 type MetricQueryResultDto struct {
-	Data []MetricQueryResultDataDto `json:"data"`
-}
-
-type MetricQueryResultDataDto struct {
-	Target     string       `json:"target"`
-	DataPoints [][2]float64 `json:"datapoints"`
+	Data []interface{} `json:"data"`
 }
 
 type UserStars struct {

+ 1 - 1
pkg/api/index.go

@@ -165,7 +165,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 				}
 			}
 
-			if c.OrgRole == m.ROLE_ADMIN {
+			if len(appLink.Children) > 0 && c.OrgRole == m.ROLE_ADMIN {
 				appLink.Children = append(appLink.Children, &dtos.NavLink{Divider: true})
 				appLink.Children = append(appLink.Children, &dtos.NavLink{Text: "Plugin Config", Icon: "fa fa-cog", Url: setting.AppSubUrl + "/plugins/" + plugin.Id + "/edit"})
 			}

+ 30 - 20
pkg/api/metrics.go

@@ -2,39 +2,49 @@ package api
 
 import (
 	"encoding/json"
-	"math/rand"
 	"net/http"
-	"strconv"
 
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/middleware"
+	"github.com/grafana/grafana/pkg/tsdb"
 	"github.com/grafana/grafana/pkg/util"
 )
 
 func GetTestMetrics(c *middleware.Context) Response {
-	from := c.QueryInt64("from")
-	to := c.QueryInt64("to")
-	maxDataPoints := c.QueryInt64("maxDataPoints")
-	stepInSeconds := (to - from) / maxDataPoints
+
+	timeRange := tsdb.NewTimeRange(c.Query("from"), c.Query("to"))
+
+	req := &tsdb.Request{
+		TimeRange: timeRange,
+		Queries: []*tsdb.Query{
+			{
+				RefId:         "A",
+				MaxDataPoints: c.QueryInt64("maxDataPoints"),
+				IntervalMs:    c.QueryInt64("intervalMs"),
+				DataSource: &tsdb.DataSourceInfo{
+					Name:     "Grafana TestDataDB",
+					PluginId: "grafana-testdata-datasource",
+				},
+			},
+		},
+	}
+
+	resp, err := tsdb.HandleRequest(req)
+	if err != nil {
+		return ApiError(500, "Metric request error", err)
+	}
 
 	result := dtos.MetricQueryResultDto{}
-	result.Data = make([]dtos.MetricQueryResultDataDto, 1)
-
-	for seriesIndex := range result.Data {
-		points := make([][2]float64, maxDataPoints)
-		walker := rand.Float64() * 100
-		time := from
-
-		for i := range points {
-			points[i][0] = walker
-			points[i][1] = float64(time)
-			walker += rand.Float64() - 0.5
-			time += stepInSeconds
+
+	for _, v := range resp.Results {
+		if v.Error != nil {
+			return ApiError(500, "tsdb.HandleRequest() response error", v.Error)
 		}
 
-		result.Data[seriesIndex].Target = "test-series-" + strconv.Itoa(seriesIndex)
-		result.Data[seriesIndex].DataPoints = points
+		for _, series := range v.Series {
+			result.Data = append(result.Data, series)
+		}
 	}
 
 	return Json(200, &result)

+ 6 - 1
pkg/plugins/frontend_plugin.go

@@ -43,7 +43,12 @@ func (fp *FrontendPluginBase) setPathsBasedOnApp(app *AppPlugin) {
 	appSubPath := strings.Replace(fp.PluginDir, app.PluginDir, "", 1)
 	fp.IncludedInAppId = app.Id
 	fp.BaseUrl = app.BaseUrl
-	fp.Module = util.JoinUrlFragments("plugins/"+app.Id, appSubPath) + "/module"
+
+	if isExternalPlugin(app.PluginDir) {
+		fp.Module = util.JoinUrlFragments("plugins/"+app.Id, appSubPath) + "/module"
+	} else {
+		fp.Module = util.JoinUrlFragments("app/plugins/app/"+app.Id, appSubPath) + "/module"
+	}
 }
 
 func (fp *FrontendPluginBase) handleModuleDefaults() {

+ 4 - 4
pkg/services/alerting/conditions/query.go

@@ -34,8 +34,8 @@ type AlertQuery struct {
 }
 
 func (c *QueryCondition) Eval(context *alerting.EvalContext) {
-	timerange := tsdb.NewTimerange(c.Query.From, c.Query.To)
-	seriesList, err := c.executeQuery(context, timerange)
+	timeRange := tsdb.NewTimeRange(c.Query.From, c.Query.To)
+	seriesList, err := c.executeQuery(context, timeRange)
 	if err != nil {
 		context.Error = err
 		return
@@ -69,7 +69,7 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext) {
 	context.Firing = len(context.EvalMatches) > 0
 }
 
-func (c *QueryCondition) executeQuery(context *alerting.EvalContext, timerange tsdb.TimeRange) (tsdb.TimeSeriesSlice, error) {
+func (c *QueryCondition) executeQuery(context *alerting.EvalContext, timeRange tsdb.TimeRange) (tsdb.TimeSeriesSlice, error) {
 	getDsInfo := &m.GetDataSourceByIdQuery{
 		Id:    c.Query.DatasourceId,
 		OrgId: context.Rule.OrgId,
@@ -79,7 +79,7 @@ func (c *QueryCondition) executeQuery(context *alerting.EvalContext, timerange t
 		return nil, fmt.Errorf("Could not find datasource")
 	}
 
-	req := c.getRequestForAlertRule(getDsInfo.Result, timerange)
+	req := c.getRequestForAlertRule(getDsInfo.Result, timeRange)
 	result := make(tsdb.TimeSeriesSlice, 0)
 
 	resp, err := c.HandleRequest(req)

+ 1 - 0
pkg/services/alerting/init/init.go

@@ -7,6 +7,7 @@ import (
 	"github.com/grafana/grafana/pkg/setting"
 	_ "github.com/grafana/grafana/pkg/tsdb/graphite"
 	_ "github.com/grafana/grafana/pkg/tsdb/prometheus"
+	_ "github.com/grafana/grafana/pkg/tsdb/testdata"
 )
 
 var engine *alerting.Engine

+ 11 - 10
pkg/tsdb/models.go

@@ -3,21 +3,22 @@ package tsdb
 import "github.com/grafana/grafana/pkg/components/simplejson"
 
 type Query struct {
-	RefId      string
-	Query      string
-	Model      *simplejson.Json
-	Depends    []string
-	DataSource *DataSourceInfo
-	Results    []*TimeSeries
-	Exclude    bool
+	RefId         string
+	Query         string
+	Model         *simplejson.Json
+	Depends       []string
+	DataSource    *DataSourceInfo
+	Results       []*TimeSeries
+	Exclude       bool
+	MaxDataPoints int64
+	IntervalMs    int64
 }
 
 type QuerySlice []*Query
 
 type Request struct {
-	TimeRange     TimeRange
-	MaxDataPoints int
-	Queries       QuerySlice
+	TimeRange TimeRange
+	Queries   QuerySlice
 }
 
 type Response struct {

+ 54 - 0
pkg/tsdb/testdata/testdata.go

@@ -0,0 +1,54 @@
+package testdata
+
+import (
+	"math/rand"
+
+	"github.com/grafana/grafana/pkg/tsdb"
+)
+
+type TestDataExecutor struct {
+	*tsdb.DataSourceInfo
+}
+
+func NewTestDataExecutor(dsInfo *tsdb.DataSourceInfo) tsdb.Executor {
+	return &TestDataExecutor{dsInfo}
+}
+
+func init() {
+	tsdb.RegisterExecutor("grafana-testdata-datasource", NewTestDataExecutor)
+}
+
+func (e *TestDataExecutor) Execute(queries tsdb.QuerySlice, context *tsdb.QueryContext) *tsdb.BatchResult {
+	result := &tsdb.BatchResult{}
+	result.QueryResults = make(map[string]*tsdb.QueryResult)
+
+	from, _ := context.TimeRange.FromTime()
+	to, _ := context.TimeRange.ToTime()
+
+	queryRes := &tsdb.QueryResult{}
+
+	for _, query := range queries {
+		// scenario := query.Model.Get("scenario").MustString("random_walk")
+		series := &tsdb.TimeSeries{Name: "test-series-0"}
+
+		stepInSeconds := (to.Unix() - from.Unix()) / query.MaxDataPoints
+		points := make([][2]*float64, 0)
+		walker := rand.Float64() * 100
+		time := from.Unix()
+
+		for i := int64(0); i < query.MaxDataPoints; i++ {
+			timestamp := float64(time)
+			val := float64(walker)
+			points = append(points, [2]*float64{&val, &timestamp})
+
+			walker += rand.Float64() - 0.5
+			time += stepInSeconds
+		}
+
+		series.Points = points
+		queryRes.Series = append(queryRes.Series, series)
+	}
+
+	result.QueryResults["A"] = queryRes
+	return result
+}

+ 10 - 1
pkg/tsdb/time_range.go

@@ -2,11 +2,12 @@ package tsdb
 
 import (
 	"fmt"
+	"strconv"
 	"strings"
 	"time"
 )
 
-func NewTimerange(from, to string) TimeRange {
+func NewTimeRange(from, to string) TimeRange {
 	return TimeRange{
 		From: from,
 		To:   to,
@@ -21,6 +22,10 @@ type TimeRange struct {
 }
 
 func (tr TimeRange) FromTime() (time.Time, error) {
+	if val, err := strconv.ParseInt(tr.From, 10, 64); err == nil {
+		return time.Unix(val, 0), nil
+	}
+
 	fromRaw := strings.Replace(tr.From, "now-", "", 1)
 
 	diff, err := time.ParseDuration("-" + fromRaw)
@@ -45,5 +50,9 @@ func (tr TimeRange) ToTime() (time.Time, error) {
 		return tr.Now.Add(diff), nil
 	}
 
+	if val, err := strconv.ParseInt(tr.To, 10, 64); err == nil {
+		return time.Unix(val, 0), nil
+	}
+
 	return time.Time{}, fmt.Errorf("cannot parse to value %s", tr.To)
 }

+ 17 - 0
pkg/tsdb/time_range_test.go

@@ -60,6 +60,23 @@ func TestTimeRange(t *testing.T) {
 			})
 		})
 
+		Convey("can parse unix epocs", func() {
+			var err error
+			tr := TimeRange{
+				From: "1474973725473",
+				To:   "1474975757930",
+				Now:  now,
+			}
+
+			res, err := tr.FromTime()
+			So(err, ShouldBeNil)
+			So(res.Unix(), ShouldEqual, 1474973725473)
+
+			res, err = tr.ToTime()
+			So(err, ShouldBeNil)
+			So(res.Unix(), ShouldEqual, 1474975757930)
+		})
+
 		Convey("Cannot parse asdf", func() {
 			var err error
 			tr := TimeRange{

+ 3 - 0
public/app/core/time_series2.ts

@@ -31,6 +31,8 @@ export default class TimeSeries {
   allIsZero: boolean;
   decimals: number;
   scaledDecimals: number;
+  hasMsResolution: boolean;
+  isOutsideRange: boolean;
 
   lines: any;
   bars: any;
@@ -54,6 +56,7 @@ export default class TimeSeries {
     this.stats = {};
     this.legend = true;
     this.unit = opts.unit;
+    this.hasMsResolution = this.isMsResolutionNeeded();
   }
 
   applySeriesOverrides(overrides) {

+ 8 - 2
public/app/core/utils/kbn.js

@@ -174,7 +174,10 @@ function($, _, moment) {
         lowLimitMs = kbn.interval_to_ms(lowLimitInterval);
       }
       else {
-        return userInterval;
+        return {
+          intervalMs: kbn.interval_to_ms(userInterval),
+          interval: userInterval,
+        };
       }
     }
 
@@ -183,7 +186,10 @@ function($, _, moment) {
       intervalMs = lowLimitMs;
     }
 
-    return kbn.secondsToHms(intervalMs / 1000);
+    return {
+      intervalMs: intervalMs,
+      interval: kbn.secondsToHms(intervalMs / 1000),
+    };
   };
 
   kbn.describe_interval = function (string) {

+ 16 - 3
public/app/features/panel/metrics_panel_ctrl.ts

@@ -25,6 +25,7 @@ class MetricsPanelCtrl extends PanelCtrl {
   range: any;
   rangeRaw: any;
   interval: any;
+  intervalMs: any;
   resolution: any;
   timeInfo: any;
   skipDataOnInit: boolean;
@@ -123,11 +124,22 @@ class MetricsPanelCtrl extends PanelCtrl {
       this.resolution = Math.ceil($(window).width() * (this.panel.span / 12));
     }
 
-    var panelInterval = this.panel.interval;
-    var datasourceInterval = (this.datasource || {}).interval;
-    this.interval = kbn.calculateInterval(this.range, this.resolution, panelInterval || datasourceInterval);
+    this.calculateInterval();
   };
 
+  calculateInterval() {
+    var intervalOverride = this.panel.interval;
+
+    // if no panel interval check datasource
+    if (!intervalOverride && this.datasource && this.datasource.interval) {
+      intervalOverride = this.datasource.interval;
+    }
+
+    var res = kbn.calculateInterval(this.range, this.resolution, intervalOverride);
+    this.interval = res.interval;
+    this.intervalMs = res.intervalMs;
+  }
+
   applyPanelTimeOverrides() {
     this.timeInfo = '';
 
@@ -183,6 +195,7 @@ class MetricsPanelCtrl extends PanelCtrl {
       range: this.range,
       rangeRaw: this.rangeRaw,
       interval: this.interval,
+      intervalMs: this.intervalMs,
       targets: this.panel.targets,
       format: this.panel.renderer === 'png' ? 'png' : 'json',
       maxDataPoints: this.resolution,

+ 2 - 2
public/app/features/templating/interval_variable.ts

@@ -54,8 +54,8 @@ export class IntervalVariable implements Variable {
       this.options.unshift({ text: 'auto', value: '$__auto_interval' });
     }
 
-    var interval = kbn.calculateInterval(this.timeSrv.timeRange(), this.auto_count, (this.auto_min ? ">"+this.auto_min : null));
-    this.templateSrv.setGrafanaVariable('$__auto_interval', interval);
+    var res = kbn.calculateInterval(this.timeSrv.timeRange(), this.auto_count, (this.auto_min ? ">"+this.auto_min : null));
+    this.templateSrv.setGrafanaVariable('$__auto_interval', res.interval);
   }
 
   updateOptions() {

+ 5 - 0
public/app/plugins/app/testdata/dashboards/graph_last_1h.json

@@ -0,0 +1,5 @@
+{
+  "title": "TestData - Graph Panel Last 1h",
+  "tags": ["testdata"],
+  "revision": 1
+}

+ 45 - 0
public/app/plugins/app/testdata/datasource/datasource.ts

@@ -0,0 +1,45 @@
+///<reference path="../../../../headers/common.d.ts" />
+
+import _ from 'lodash';
+
+class TestDataDatasource {
+
+  /** @ngInject */
+  constructor(private backendSrv, private $q) {}
+
+  query(options) {
+    var queries = _.filter(options.targets, item => {
+      return item.hide !== true;
+    });
+
+    if (queries.length === 0) {
+      return this.$q.when({data: []});
+    }
+
+    return this.backendSrv.get('/api/metrics/test', {
+      from: options.range.from.valueOf(),
+      to: options.range.to.valueOf(),
+      scenario: options.targets[0].scenario,
+      interval: options.intervalMs,
+      maxDataPoints: options.maxDataPoints,
+    }).then(res => {
+      res.data = res.data.map(item => {
+        return {target: item.name, datapoints: item.points};
+      });
+
+      return res;
+    });
+  }
+
+  annotationQuery(options) {
+    return this.backendSrv.get('/api/annotations', {
+      from: options.range.from.valueOf(),
+      to: options.range.to.valueOf(),
+      limit: options.limit,
+      type: options.type,
+    });
+  }
+
+}
+
+export {TestDataDatasource};

+ 22 - 0
public/app/plugins/app/testdata/datasource/module.ts

@@ -0,0 +1,22 @@
+///<reference path="../../../../headers/common.d.ts" />
+
+import {TestDataDatasource} from './datasource';
+import {TestDataQueryCtrl} from './query_ctrl';
+
+class TestDataAnnotationsQueryCtrl {
+  annotation: any;
+
+  constructor() {
+  }
+
+  static template = '<h2>test data</h2>';
+}
+
+
+export {
+  TestDataDatasource,
+  TestDataDatasource as Datasource,
+  TestDataQueryCtrl as QueryCtrl,
+  TestDataAnnotationsQueryCtrl as AnnotationsQueryCtrl,
+};
+

+ 19 - 0
public/app/plugins/app/testdata/datasource/plugin.json

@@ -0,0 +1,19 @@
+{
+  "type": "datasource",
+  "name": "Grafana TestDataDB",
+  "id": "grafana-testdata-datasource",
+
+  "metrics": true,
+  "annotations": true,
+
+  "info": {
+    "author": {
+      "name": "Grafana Project",
+      "url": "http://grafana.org"
+    },
+    "logos": {
+      "small": "",
+      "large": ""
+    }
+  }
+}

+ 24 - 0
public/app/plugins/app/testdata/datasource/query_ctrl.ts

@@ -0,0 +1,24 @@
+///<reference path="../../../../headers/common.d.ts" />
+
+import {TestDataDatasource} from './datasource';
+import {QueryCtrl} from 'app/plugins/sdk';
+
+export class TestDataQueryCtrl extends QueryCtrl {
+  static templateUrl = 'partials/query.editor.html';
+
+  scenarioDefs: any;
+
+  /** @ngInject **/
+  constructor($scope, $injector) {
+    super($scope, $injector);
+
+    this.target.scenario = this.target.scenario || 'random_walk';
+
+    this.scenarioDefs = {
+      'random_walk': {text: 'Random Walk'},
+      'no_datapoints': {text: 'No Datapoints'},
+      'data_outside_range': {text: 'Data Outside Range'},
+    };
+  }
+}
+

+ 36 - 0
public/app/plugins/app/testdata/module.ts

@@ -0,0 +1,36 @@
+///<reference path="../../../headers/common.d.ts" />
+
+export class ConfigCtrl {
+  static template = '';
+
+  appEditCtrl: any;
+
+  constructor(private backendSrv) {
+    this.appEditCtrl.setPreUpdateHook(this.initDatasource.bind(this));
+  }
+
+  initDatasource() {
+    return this.backendSrv.get('/api/datasources').then(res => {
+      var found = false;
+      for (let ds of res) {
+        if (ds.type === "grafana-testdata-datasource") {
+          found = true;
+        }
+      }
+
+      if (!found) {
+        var dsInstance = {
+          name: 'Grafana TestData',
+          type: 'grafana-testdata-datasource',
+          access: 'direct',
+          jsonData: {}
+        };
+
+        return this.backendSrv.post('/api/datasources', dsInstance);
+      }
+
+      return Promise.resolve();
+    });
+  }
+}
+

+ 22 - 0
public/app/plugins/app/testdata/partials/query.editor.html

@@ -0,0 +1,22 @@
+<query-editor-row query-ctrl="ctrl" has-text-edit-mode="false">
+	<div class="gf-form-inline">
+		<div class="gf-form">
+			<label class="gf-form-label query-keyword">Scenario</label>
+			<div class="gf-form-select-wrapper width-20">
+				<select class="gf-form-input width-20" ng-model="ctrl.target.scenario" ng-options="k as v.text for (k, v) in ctrl.scenarioDefs" ng-change="ctrl.refresh()"></select>
+			</div>
+		</div>
+		<div class="gf-form">
+			<label class="gf-form-label query-keyword">With Options</label>
+			<input type="text" class="gf-form-input" placeholder="optional" ng-model="target.param1" ng-change="ctrl.refresh()" ng-model-onblur>
+		</div>
+		<div class="gf-form">
+			<label class="gf-form-label query-keyword">Alias</label>
+			<input type="text" class="gf-form-input" placeholder="optional" ng-model="target.alias" ng-change="ctrl.refresh()" ng-model-onblur>
+		</div>
+		<div class="gf-form gf-form--grow">
+			<div class="gf-form-label gf-form-label--grow"></div>
+		</div>
+	</div>
+</query-editor-row>
+

+ 27 - 0
public/app/plugins/app/testdata/plugin.json

@@ -0,0 +1,27 @@
+{
+  "type": "app",
+  "name": "Grafana TestData",
+  "id": "testdata",
+
+  "info": {
+    "description": "Grafana test data app",
+    "author": {
+      "name": "Grafana Project",
+      "url": "http://grafana.org"
+    },
+    "version": "1.0.5",
+    "updated": "2016-09-26"
+  },
+
+  "includes": [
+    {
+      "type": "dashboard",
+      "name": "TestData - Graph Last 1h",
+      "path": "dashboards/graph_last_1h.json"
+    }
+  ],
+
+  "dependencies": {
+    "grafanaVersion": "4.x.x"
+  }
+}

+ 32 - 36
public/app/plugins/panel/graph/data_processor.ts

@@ -2,6 +2,7 @@
 
 import kbn from 'app/core/utils/kbn';
 import _ from 'lodash';
+import moment from 'moment';
 import TimeSeries from 'app/core/time_series2';
 import {colors} from 'app/core/core';
 
@@ -28,8 +29,10 @@ export class DataProcessor {
 
     switch (this.panel.xaxis.mode) {
       case 'series':
-        case 'time': {
-        return options.dataList.map(this.timeSeriesHandler.bind(this));
+      case 'time': {
+        return options.dataList.map((item, index) => {
+          return this.timeSeriesHandler(item, index, options);
+        });
       }
       case 'field': {
         return this.customHandler(firstItem);
@@ -74,33 +77,26 @@ export class DataProcessor {
     }
   }
 
-  seriesHandler(seriesData, index, datapoints, alias) {
+  timeSeriesHandler(seriesData, index, options) {
+    var datapoints = seriesData.datapoints;
+    var alias = seriesData.target;
+
     var colorIndex = index % colors.length;
     var color = this.panel.aliasColors[alias] || colors[colorIndex];
 
     var series = new TimeSeries({datapoints: datapoints, alias: alias, color: color, unit: seriesData.unit});
 
-    // if (datapoints && datapoints.length > 0) {
-    //   var last = moment.utc(datapoints[datapoints.length - 1][1]);
-    //   var from = moment.utc(this.range.from);
-    //   if (last - from < -10000) {
-    //     this.datapointsOutside = true;
-    //   }
-    //
-    //   this.datapointsCount += datapoints.length;
-    //   this.panel.tooltip.msResolution = this.panel.tooltip.msResolution || series.isMsResolutionNeeded();
-    // }
+    if (datapoints && datapoints.length > 0) {
+      var last = datapoints[datapoints.length - 1][1];
+      var from = options.range.from;
+      if (last - from < -10000) {
+        series.isOutsideRange = true;
+      }
+    }
 
     return series;
   }
 
-  timeSeriesHandler(seriesData, index) {
-    var datapoints = seriesData.datapoints;
-    var alias = seriesData.target;
-
-    return this.seriesHandler(seriesData, index, datapoints, alias);
-  }
-
   customHandler(dataItem) {
     console.log('custom', dataItem);
     let nameField = this.panel.xaxis.name;
@@ -126,21 +122,21 @@ export class DataProcessor {
     return [];
   }
 
-  tableHandler(seriesData, index) {
-    var xColumnIndex = Number(this.panel.xaxis.columnIndex);
-    var valueColumnIndex = Number(this.panel.xaxis.valueColumnIndex);
-    var datapoints = _.map(seriesData.rows, (row) => {
-      var value = valueColumnIndex ? row[valueColumnIndex] : _.last(row);
-      return [
-        value,             // Y value
-        row[xColumnIndex]  // X value
-      ];
-    });
-
-    var alias = seriesData.columns[valueColumnIndex].text;
-
-    return this.seriesHandler(seriesData, index, datapoints, alias);
-  }
+  // tableHandler(seriesData, index) {
+  //   var xColumnIndex = Number(this.panel.xaxis.columnIndex);
+  //   var valueColumnIndex = Number(this.panel.xaxis.valueColumnIndex);
+  //   var datapoints = _.map(seriesData.rows, (row) => {
+  //     var value = valueColumnIndex ? row[valueColumnIndex] : _.last(row);
+  //     return [
+  //       value,             // Y value
+  //       row[xColumnIndex]  // X value
+  //     ];
+  //   });
+  //
+  //   var alias = seriesData.columns[valueColumnIndex].text;
+  //
+  //   return this.seriesHandler(seriesData, index, datapoints, alias);
+  // }
 
   // esRawDocHandler(seriesData, index) {
   //   let xField = this.panel.xaxis.esField;
@@ -160,7 +156,7 @@ export class DataProcessor {
   //   var alias = valueField;
   //   return this.seriesHandler(seriesData, index, datapoints, alias);
   // }
-  //
+
   validateXAxisSeriesValue() {
     switch (this.panel.xaxis.mode) {
       case 'series': {

+ 7 - 7
public/app/plugins/panel/graph/graph_tooltip.js

@@ -121,20 +121,20 @@ function ($, _) {
       var seriesList = getSeriesFn();
       var group, value, absoluteTime, hoverInfo, i, series, seriesHtml, tooltipFormat;
 
-      if (panel.tooltip.msResolution) {
-        tooltipFormat = 'YYYY-MM-DD HH:mm:ss.SSS';
-      } else {
-        tooltipFormat = 'YYYY-MM-DD HH:mm:ss';
-      }
-
       if (dashboard.sharedCrosshair) {
-        ctrl.publishAppEvent('setCrosshair', { pos: pos, scope: scope });
+        ctrl.publishAppEvent('setCrosshair', {pos: pos, scope: scope});
       }
 
       if (seriesList.length === 0) {
         return;
       }
 
+      if (seriesList[0].hasMsResolution) {
+        tooltipFormat = 'YYYY-MM-DD HH:mm:ss.SSS';
+      } else {
+        tooltipFormat = 'YYYY-MM-DD HH:mm:ss';
+      }
+
       if (panel.tooltip.shared) {
         plot.unhighlight();
 

+ 11 - 5
public/app/plugins/panel/graph/module.ts

@@ -25,7 +25,6 @@ class GraphCtrl extends MetricsPanelCtrl {
   annotationsPromise: any;
   datapointsCount: number;
   datapointsOutside: boolean;
-  datapointsWarning: boolean;
   colors: any = [];
   subTabIndex: number;
   processor: DataProcessor;
@@ -172,13 +171,20 @@ class GraphCtrl extends MetricsPanelCtrl {
   }
 
   onDataReceived(dataList) {
-    this.datapointsWarning = false;
-    this.datapointsCount = 0;
-    this.datapointsOutside = false;
 
     this.dataList = dataList;
     this.seriesList = this.processor.getSeriesList({dataList: dataList, range: this.range});
-    this.datapointsWarning = this.datapointsCount === 0 || this.datapointsOutside;
+
+    this.datapointsCount = this.seriesList.reduce((prev, series) => {
+      return prev + series.datapoints.length;
+    }, 0);
+
+    this.datapointsOutside = false;
+    for (let series of this.seriesList) {
+      if (series.isOutsideRange) {
+        this.datapointsOutside = true;
+      }
+    }
 
     this.annotationsPromise.then(annotations => {
       this.loading = false;

+ 23 - 33
public/app/plugins/panel/graph/specs/graph_ctrl_specs.ts

@@ -3,6 +3,7 @@
 import {describe, beforeEach, it, sinon, expect, angularMocks} from '../../../../../test/lib/common';
 
 import angular from 'angular';
+import moment from 'moment';
 import {GraphCtrl} from '../module';
 import helpers from '../../../../../test/specs/helpers';
 
@@ -19,64 +20,53 @@ describe('GraphCtrl', function() {
     ctx.ctrl.updateTimeRange();
   });
 
-  describe.skip('msResolution with second resolution timestamps', function() {
-    beforeEach(function() {
-      var data = [
-        { target: 'test.cpu1', datapoints: [[45, 1234567890], [60, 1234567899]]},
-        { target: 'test.cpu2', datapoints: [[55, 1236547890], [90, 1234456709]]}
-      ];
-      ctx.ctrl.panel.tooltip.msResolution = false;
-      ctx.ctrl.onDataReceived(data);
-    });
+  describe('when time series are outside range', function() {
 
-    it('should not show millisecond resolution tooltip', function() {
-      expect(ctx.ctrl.panel.tooltip.msResolution).to.be(false);
-    });
-  });
-
-  describe.skip('msResolution with millisecond resolution timestamps', function() {
     beforeEach(function() {
       var data = [
-        { target: 'test.cpu1', datapoints: [[45, 1234567890000], [60, 1234567899000]]},
-        { target: 'test.cpu2', datapoints: [[55, 1236547890001], [90, 1234456709000]]}
+        {target: 'test.cpu1', datapoints: [[45, 1234567890], [60, 1234567899]]},
       ];
-      ctx.ctrl.panel.tooltip.msResolution = false;
+
+      ctx.ctrl.range = {from: moment().valueOf(), to: moment().valueOf()};
       ctx.ctrl.onDataReceived(data);
     });
 
-    it('should show millisecond resolution tooltip', function() {
-      expect(ctx.ctrl.panel.tooltip.msResolution).to.be(true);
+    it('should set datapointsOutside', function() {
+      expect(ctx.ctrl.datapointsOutside).to.be(true);
     });
   });
 
-  describe.skip('msResolution with millisecond resolution timestamps but with trailing zeroes', function() {
+  describe('when time series are inside range', function() {
     beforeEach(function() {
+      var range = {
+        from: moment().subtract(1, 'days').valueOf(),
+        to: moment().valueOf()
+      };
+
       var data = [
-        { target: 'test.cpu1', datapoints: [[45, 1234567890000], [60, 1234567899000]]},
-        { target: 'test.cpu2', datapoints: [[55, 1236547890000], [90, 1234456709000]]}
+        {target: 'test.cpu1', datapoints: [[45, range.from + 1000], [60, range.from + 10000]]},
       ];
-      ctx.ctrl.panel.tooltip.msResolution = false;
+
+      ctx.ctrl.range = range;
       ctx.ctrl.onDataReceived(data);
     });
 
-    it('should not show millisecond resolution tooltip', function() {
-      expect(ctx.ctrl.panel.tooltip.msResolution).to.be(false);
+    it('should set datapointsOutside', function() {
+      expect(ctx.ctrl.datapointsOutside).to.be(false);
     });
   });
 
-  describe.skip('msResolution with millisecond resolution timestamps in one of the series', function() {
+  describe('datapointsCount given 2 series', function() {
     beforeEach(function() {
       var data = [
-        { target: 'test.cpu1', datapoints: [[45, 1234567890000], [60, 1234567899000]]},
-        { target: 'test.cpu2', datapoints: [[55, 1236547890010], [90, 1234456709000]]},
-        { target: 'test.cpu3', datapoints: [[65, 1236547890000], [120, 1234456709000]]}
+        {target: 'test.cpu1', datapoints: [[45, 1234567890], [60, 1234567899]]},
+        {target: 'test.cpu2', datapoints: [[45, 1234567890]]},
       ];
-      ctx.ctrl.panel.tooltip.msResolution = false;
       ctx.ctrl.onDataReceived(data);
     });
 
-    it('should show millisecond resolution tooltip', function() {
-      expect(ctx.ctrl.panel.tooltip.msResolution).to.be(true);
+    it('should set datapointsCount to sum of datapoints', function() {
+      expect(ctx.ctrl.datapointsCount).to.be(3);
     });
   });
 

+ 6 - 3
public/app/plugins/panel/graph/template.ts

@@ -2,11 +2,14 @@ var template = `
 <div class="graph-wrapper" ng-class="{'graph-legend-rightside': ctrl.panel.legend.rightSide}">
   <div class="graph-canvas-wrapper">
 
-    <div ng-if="datapointsWarning" class="datapoints-warning">
-      <span class="small" ng-show="!datapointsCount">
+    <div class="datapoints-warning" ng-show="ctrl.datapointsCount===0">
+      <span class="small" >
         No datapoints <tip>No datapoints returned from metric query</tip>
       </span>
-      <span class="small" ng-show="datapointsOutside">
+    </div>
+
+    <div class="datapoints-warning" ng-show="ctrl.datapointsOutside">
+      <span class="small">
         Datapoints outside time range
         <tip>Can be caused by timezone mismatch between browser and graphite server</tip>
       </span>

+ 1 - 1
public/app/plugins/panel/pluginlist/plugin.json

@@ -7,7 +7,7 @@
     "author": {
       "name": "Grafana Project",
       "url": "http://grafana.org"
-},
+    },
     "logos": {
       "small": "img/icn-dashlist-panel.svg",
       "large": "img/icn-dashlist-panel.svg"

+ 32 - 0
public/test/core/time_series_specs.js

@@ -56,6 +56,38 @@ define([
       });
     });
 
+    describe('When checking if ms resolution is needed', function() {
+      describe('msResolution with second resolution timestamps', function() {
+        beforeEach(function() {
+          series = new TimeSeries({datapoints: [[45, 1234567890], [60, 1234567899]]});
+        });
+
+        it('should set hasMsResolution to false', function() {
+          expect(series.hasMsResolution).to.be(false);
+        });
+      });
+
+      describe('msResolution with millisecond resolution timestamps', function() {
+        beforeEach(function() {
+          series = new TimeSeries({datapoints: [[55, 1236547890001], [90, 1234456709000]]});
+        });
+
+        it('should show millisecond resolution tooltip', function() {
+          expect(series.hasMsResolution).to.be(true);
+        });
+      });
+
+      describe('msResolution with millisecond resolution timestamps but with trailing zeroes', function() {
+        beforeEach(function() {
+          series = new TimeSeries({datapoints: [[45, 1234567890000], [60, 1234567899000]]});
+        });
+
+        it('should not show millisecond resolution tooltip', function() {
+          expect(series.hasMsResolution).to.be(false);
+        });
+      });
+    });
+
     describe('can detect if series contains ms precision', function() {
       var fakedata;
 

+ 36 - 34
public/test/core/utils/kbn_specs.js

@@ -132,62 +132,64 @@ define([
   describe('calculateInterval', function() {
     it('1h 100 resultion', function() {
       var range = { from: dateMath.parse('now-1h'), to: dateMath.parse('now') };
-      var str = kbn.calculateInterval(range, 100, null);
-      expect(str).to.be('30s');
+      var res = kbn.calculateInterval(range, 100, null);
+      expect(res.interval).to.be('30s');
     });
 
     it('10m 1600 resolution', function() {
       var range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
-      var str = kbn.calculateInterval(range, 1600, null);
-      expect(str).to.be('500ms');
+      var res = kbn.calculateInterval(range, 1600, null);
+      expect(res.interval).to.be('500ms');
+      expect(res.intervalMs).to.be(500);
     });
 
     it('fixed user interval', function() {
       var range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
-      var str = kbn.calculateInterval(range, 1600, '10s');
-      expect(str).to.be('10s');
+      var res = kbn.calculateInterval(range, 1600, '10s');
+      expect(res.interval).to.be('10s');
+      expect(res.intervalMs).to.be(10000);
     });
 
     it('short time range and user low limit', function() {
       var range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
-      var str = kbn.calculateInterval(range, 1600, '>10s');
-      expect(str).to.be('10s');
+      var res = kbn.calculateInterval(range, 1600, '>10s');
+      expect(res.interval).to.be('10s');
     });
 
     it('large time range and user low limit', function() {
-      var range = { from: dateMath.parse('now-14d'), to: dateMath.parse('now') };
-      var str = kbn.calculateInterval(range, 1000, '>10s');
-      expect(str).to.be('20m');
+      var range = {from: dateMath.parse('now-14d'), to: dateMath.parse('now')};
+      var res = kbn.calculateInterval(range, 1000, '>10s');
+      expect(res.interval).to.be('20m');
     });
-	
+
     it('10s 900 resolution and user low limit in ms', function() {
       var range = { from: dateMath.parse('now-10s'), to: dateMath.parse('now') };
-      var str = kbn.calculateInterval(range, 900, '>15ms');
-      expect(str).to.be('15ms');
+      var res = kbn.calculateInterval(range, 900, '>15ms');
+      expect(res.interval).to.be('15ms');
     });
   });
 
   describe('hex', function() {
-      it('positive integer', function() {
-	var str = kbn.valueFormats.hex(100, 0);
-	expect(str).to.be('64');
-      });
-      it('negative integer', function() {
-	var str = kbn.valueFormats.hex(-100, 0);
-	expect(str).to.be('-64');
-      });
-      it('null', function() {
-	var str = kbn.valueFormats.hex(null, 0);
-	expect(str).to.be('');
-      });
-      it('positive float', function() {
-	var str = kbn.valueFormats.hex(50.52, 1);
-	expect(str).to.be('32.8');
-      }); 
-      it('negative float', function() {
-	var str = kbn.valueFormats.hex(-50.333, 2);
-	expect(str).to.be('-32.547AE147AE14');
-      });
+    it('positive integer', function() {
+      var str = kbn.valueFormats.hex(100, 0);
+      expect(str).to.be('64');
+    });
+    it('negative integer', function() {
+      var str = kbn.valueFormats.hex(-100, 0);
+      expect(str).to.be('-64');
+    });
+    it('null', function() {
+      var str = kbn.valueFormats.hex(null, 0);
+      expect(str).to.be('');
+    });
+    it('positive float', function() {
+      var str = kbn.valueFormats.hex(50.52, 1);
+      expect(str).to.be('32.8');
+    });
+    it('negative float', function() {
+      var str = kbn.valueFormats.hex(-50.333, 2);
+      expect(str).to.be('-32.547AE147AE14');
+    });
   });
 
   describe('hex 0x', function() {