Ver código fonte

Merge remote-tracking branch 'upstream/master' into postgres-query-builder

Sven Klemm 7 anos atrás
pai
commit
046ee8efd8
44 arquivos alterados com 1289 adições e 424 exclusões
  1. 10 0
      CHANGELOG.md
  2. 6 0
      docker/blocks/prometheus/docker-compose.yaml
  3. 6 2
      docker/blocks/prometheus/prometheus.yml
  4. 6 0
      docker/blocks/prometheus2/docker-compose.yaml
  5. 6 2
      docker/blocks/prometheus2/prometheus.yml
  6. 18 0
      docker/blocks/prometheus_random_data/Dockerfile
  7. 2 1
      docs/sources/features/datasources/cloudwatch.md
  8. 1 1
      docs/sources/features/datasources/prometheus.md
  9. 1 0
      docs/sources/http_api/index.md
  10. 2 2
      pkg/api/api.go
  11. 8 2
      pkg/api/pluginproxy/ds_proxy.go
  12. 3 3
      pkg/plugins/update_checker.go
  13. 27 9
      pkg/services/alerting/notifiers/pagerduty.go
  14. 1 1
      pkg/services/sqlstore/alert.go
  15. 1 1
      pkg/services/sqlstore/dashboard.go
  16. 15 0
      public/app/core/specs/kbn.jest.ts
  17. 50 2
      public/app/core/utils/kbn.ts
  18. 58 0
      public/app/core/utils/ticks.ts
  19. 10 3
      public/app/plugins/datasource/cloudwatch/datasource.ts
  20. 9 0
      public/app/plugins/datasource/prometheus/config_ctrl.ts
  21. 59 151
      public/app/plugins/datasource/prometheus/datasource.ts
  22. 1 4
      public/app/plugins/datasource/prometheus/module.ts
  23. 14 4
      public/app/plugins/datasource/prometheus/partials/config.html
  24. 8 1
      public/app/plugins/datasource/prometheus/query_ctrl.ts
  25. 199 0
      public/app/plugins/datasource/prometheus/result_transformer.ts
  26. 104 0
      public/app/plugins/datasource/prometheus/specs/datasource.jest.ts
  27. 70 50
      public/app/plugins/datasource/prometheus/specs/datasource_specs.ts
  28. 1 1
      public/app/plugins/datasource/prometheus/specs/metric_find_query_specs.ts
  29. 118 0
      public/app/plugins/datasource/prometheus/specs/result_transformer.jest.ts
  30. 11 6
      public/app/plugins/panel/graph/data_processor.ts
  31. 10 6
      public/app/plugins/panel/graph/graph.ts
  32. 37 6
      public/app/plugins/panel/graph/histogram.ts
  33. 44 0
      public/app/plugins/panel/graph/specs/graph_specs.ts
  34. 3 3
      public/app/plugins/panel/graph/specs/histogram.jest.ts
  35. 1 1
      public/app/plugins/panel/graph/tab_display.html
  36. 7 0
      public/app/plugins/panel/heatmap/axes_editor.ts
  37. 95 48
      public/app/plugins/panel/heatmap/heatmap_ctrl.ts
  38. 47 4
      public/app/plugins/panel/heatmap/heatmap_data_converter.ts
  39. 35 11
      public/app/plugins/panel/heatmap/heatmap_tooltip.ts
  40. 28 15
      public/app/plugins/panel/heatmap/partials/axes_editor.html
  41. 80 58
      public/app/plugins/panel/heatmap/rendering.ts
  42. 34 22
      public/app/plugins/panel/heatmap/specs/heatmap_data_converter.jest.ts
  43. 39 2
      public/app/plugins/panel/heatmap/specs/renderer_specs.ts
  44. 4 2
      public/app/plugins/panel/singlestat/module.ts

+ 10 - 0
CHANGELOG.md

@@ -1,13 +1,23 @@
 # 5.1.0 (unreleased)
 
+* **Prometheus**: The heatmap panel now support Prometheus histograms [#10009](https://github.com/grafana/grafana/issues/10009)
 * **Postgres/MySQL**: Ability to insert 0s or nulls for missing intervals [#9487](https://github.com/grafana/grafana/issues/9487), thanks [@svenklemm](https://github.com/svenklemm)
 * **Graph**: Thresholds for Right Y axis [#7107](https://github.com/grafana/grafana/issues/7107), thx [@ilgizar](https://github.com/ilgizar)
+* **Graph**: Support multiple series stacking in histogram mode [#8151](https://github.com/grafana/grafana/issues/8151), thx [@mtanda](https://github.com/mtanda)
 * **Alerting**: Pausing/un alerts now updates new_state_date [#10942](https://github.com/grafana/grafana/pull/10942)
 * **Templating**: Add comma templating format [#10632](https://github.com/grafana/grafana/issues/10632), thx [@mtanda](https://github.com/mtanda)
+* **Prometheus**: Support POST for query and query_range [#9859](https://github.com/grafana/grafana/pull/9859), thx [@mtanda](https://github.com/mtanda)
 
 ### Minor
 * **OpsGenie**: Add triggered alerts as description [#11046](https://github.com/grafana/grafana/pull/11046), thx [@llamashoes](https://github.com/llamashoes)
 * **Cloudwatch**: Support high resolution metrics [#10925](https://github.com/grafana/grafana/pull/10925), thx [@mtanda](https://github.com/mtanda)
+* **Cloudwatch**: Add dimension filtering to CloudWatch `dimension_values()` [#10029](https://github.com/grafana/grafana/issues/10029), thx [@willyhutw](https://github.com/willyhutw)
+* **Units**: Second to HH:mm:ss formatter [#11107](https://github.com/grafana/grafana/issues/11107), thx [@gladdiologist](https://github.com/gladdiologist) 
+* **Singlestat**: Add color to prefix and postfix in singlestat panel [#11143](https://github.com/grafana/grafana/pull/11143), thx [@ApsOps](https://github.com/ApsOps)
+
+# 5.0.2 (unrelease)
+
+* **Teams**: Remove quota restrictions from teams [#11220](https://github.com/grafana/grafana/issues/11220)
 
 # 5.0.1 (2018-03-08)
 

+ 6 - 0
docker/blocks/prometheus/docker-compose.yaml

@@ -23,3 +23,9 @@
     network_mode: host
     ports:
       - "9093:9093"
+
+  prometheus-random-data:
+    build: blocks/prometheus_random_data
+    network_mode: host
+    ports:
+      - "8080:8080"

+ 6 - 2
docker/blocks/prometheus/prometheus.yml

@@ -25,11 +25,15 @@ scrape_configs:
   - job_name: 'node_exporter'
     static_configs:
       - targets: ['127.0.0.1:9100']
- 
+
   - job_name: 'fake-data-gen'
     static_configs:
       - targets: ['127.0.0.1:9091']
-  
+
   - job_name: 'grafana'
     static_configs:
       - targets: ['127.0.0.1:3000']
+
+  - job_name: 'prometheus-random-data'
+    static_configs:
+      - targets: ['127.0.0.1:8080']

+ 6 - 0
docker/blocks/prometheus2/docker-compose.yaml

@@ -23,3 +23,9 @@
     network_mode: host
     ports:
       - "9093:9093"
+
+  prometheus-random-data:
+    build: blocks/prometheus_random_data
+    network_mode: host
+    ports:
+      - "8080:8080"

+ 6 - 2
docker/blocks/prometheus2/prometheus.yml

@@ -25,11 +25,15 @@ scrape_configs:
   - job_name: 'node_exporter'
     static_configs:
       - targets: ['127.0.0.1:9100']
- 
+
   - job_name: 'fake-data-gen'
     static_configs:
       - targets: ['127.0.0.1:9091']
-  
+
   - job_name: 'grafana'
     static_configs:
       - targets: ['127.0.0.1:3000']
+
+  - job_name: 'prometheus-random-data'
+    static_configs:
+      - targets: ['127.0.0.1:8080']

+ 18 - 0
docker/blocks/prometheus_random_data/Dockerfile

@@ -0,0 +1,18 @@
+# This Dockerfile builds an image for a client_golang example.
+
+# Builder image, where we build the example.
+FROM golang:1.9.0 AS builder
+# Download prometheus/client_golang/examples/random first
+RUN go get github.com/prometheus/client_golang/examples/random
+WORKDIR /go/src/github.com/prometheus/client_golang
+WORKDIR /go/src/github.com/prometheus/client_golang/prometheus
+RUN go get -d
+WORKDIR /go/src/github.com/prometheus/client_golang/examples/random
+RUN CGO_ENABLED=0 GOOS=linux go build -a -tags netgo -ldflags '-w'
+
+# Final image.
+FROM scratch
+LABEL maintainer "The Prometheus Authors <prometheus-developers@googlegroups.com>"
+COPY --from=builder /go/src/github.com/prometheus/client_golang/examples/random .
+EXPOSE 8080
+ENTRYPOINT ["/random"]

+ 2 - 1
docs/sources/features/datasources/cloudwatch.md

@@ -87,7 +87,7 @@ Name | Description
 *namespaces()* | Returns a list of namespaces CloudWatch support.
 *metrics(namespace, [region])* | Returns a list of metrics in the namespace. (specify region or use "default" for custom metrics)
 *dimension_keys(namespace)* | Returns a list of dimension keys in the namespace.
-*dimension_values(region, namespace, metric, dimension_key)* | Returns a list of dimension values matching the specified `region`, `namespace`, `metric` and `dimension_key`.
+*dimension_values(region, namespace, metric, dimension_key, [filters])* | Returns a list of dimension values matching the specified `region`, `namespace`, `metric`, `dimension_key` or you can use dimension `filters` to get more specific result as well.
 *ebs_volume_ids(region, instance_id)* | Returns a list of volume ids matching the specified `region`, `instance_id`.
 *ec2_instance_attribute(region, attribute_name, filters)* | Returns a list of attributes matching the specified `region`, `attribute_name`, `filters`.
 
@@ -104,6 +104,7 @@ Query | Service
 *dimension_values(us-east-1,AWS/Redshift,CPUUtilization,ClusterIdentifier)* | RedShift
 *dimension_values(us-east-1,AWS/RDS,CPUUtilization,DBInstanceIdentifier)* | RDS
 *dimension_values(us-east-1,AWS/S3,BucketSizeBytes,BucketName)* | S3
+*dimension_values(us-east-1,CWAgent,disk_used_percent,device,{"InstanceId":"$instance_id"})* | CloudWatch Agent
 
 ## ec2_instance_attribute examples
 

+ 1 - 1
docs/sources/features/datasources/prometheus.md

@@ -93,7 +93,7 @@ queries via the Dashboard menu / Annotations view.
 Prometheus supports two ways to query annotations.
 
 - A regular metric query
-- A Prometheus query for pending and firing alerts (for details see [Inspecting alerts during runtime](https://prometheus.io/docs/alerting/rules/#inspecting-alerts-during-runtime))
+- A Prometheus query for pending and firing alerts (for details see [Inspecting alerts during runtime](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/#inspecting-alerts-during-runtime))
 
 The step option is useful to limit the number of events returned from your query.
 

+ 1 - 0
docs/sources/http_api/index.md

@@ -31,6 +31,7 @@ dashboards, creating users and updating data sources.
 * [Annotations API]({{< relref "http_api/annotations.md" >}})
 * [Alerting API]({{< relref "http_api/alerting.md" >}})
 * [User API]({{< relref "http_api/user.md" >}})
+* [Team API]({{< relref "http_api/team.md" >}})
 * [Admin API]({{< relref "http_api/admin.md" >}})
 * [Preferences API]({{< relref "http_api/preferences.md" >}})
 * [Other API]({{< relref "http_api/other.md" >}})

+ 2 - 2
pkg/api/api.go

@@ -150,11 +150,11 @@ func (hs *HttpServer) registerRoutes() {
 		apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
 			teamsRoute.Get("/:teamId", wrap(GetTeamById))
 			teamsRoute.Get("/search", wrap(SearchTeams))
-			teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam))
+			teamsRoute.Post("/", bind(m.CreateTeamCommand{}), wrap(CreateTeam))
 			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
 			teamsRoute.Delete("/:teamId", wrap(DeleteTeamById))
 			teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers))
-			teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
+			teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
 			teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember))
 		}, reqOrgAdmin)
 

+ 8 - 2
pkg/api/pluginproxy/ds_proxy.go

@@ -189,8 +189,14 @@ func (proxy *DataSourceProxy) validateRequest() error {
 	}
 
 	if proxy.ds.Type == m.DS_PROMETHEUS {
-		if proxy.ctx.Req.Request.Method != http.MethodGet || !strings.HasPrefix(proxy.proxyPath, "api/") {
-			return errors.New("GET is only allowed on proxied Prometheus datasource")
+		if proxy.ctx.Req.Request.Method == "DELETE" {
+			return errors.New("Deletes not allowed on proxied Prometheus datasource")
+		}
+		if proxy.ctx.Req.Request.Method == "PUT" {
+			return errors.New("Puts not allowed on proxied Prometheus datasource")
+		}
+		if proxy.ctx.Req.Request.Method == "POST" && !(proxy.proxyPath == "api/v1/query" || proxy.proxyPath == "api/v1/query_range") {
+			return errors.New("Posts not allowed on proxied Prometheus datasource except on /query and /query_range")
 		}
 	}
 

+ 3 - 3
pkg/plugins/update_checker.go

@@ -63,7 +63,7 @@ func checkForUpdates() {
 	resp, err := httpClient.Get("https://grafana.com/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + setting.BuildVersion)
 
 	if err != nil {
-		log.Trace("Failed to get plugins repo from grafana.net, %v", err.Error())
+		log.Trace("Failed to get plugins repo from grafana.com, %v", err.Error())
 		return
 	}
 
@@ -101,7 +101,7 @@ func checkForUpdates() {
 
 	resp2, err := httpClient.Get("https://raw.githubusercontent.com/grafana/grafana/master/latest.json")
 	if err != nil {
-		log.Trace("Failed to get latest.json repo from github: %v", err.Error())
+		log.Trace("Failed to get latest.json repo from github.com: %v", err.Error())
 		return
 	}
 
@@ -115,7 +115,7 @@ func checkForUpdates() {
 	var githubLatest GithubLatest
 	err = json.Unmarshal(body, &githubLatest)
 	if err != nil {
-		log.Trace("Failed to unmarshal github latest, reading response from github: %v", err.Error())
+		log.Trace("Failed to unmarshal github.com latest, reading response from github.com: %v", err.Error())
 		return
 	}
 

+ 27 - 9
pkg/services/alerting/notifiers/pagerduty.go

@@ -1,7 +1,9 @@
 package notifiers
 
 import (
+	"os"
 	"strconv"
+	"time"
 
 	"fmt"
 
@@ -38,7 +40,7 @@ func init() {
 }
 
 var (
-	pagerdutyEventApiUrl string = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
+	pagerdutyEventApiUrl string = "https://events.pagerduty.com/v2/enqueue"
 )
 
 func NewPagerdutyNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
@@ -85,28 +87,41 @@ func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error {
 
 	this.log.Info("Notifying Pagerduty", "event_type", eventType)
 
+	payloadJSON := simplejson.New()
+	payloadJSON.Set("summary", evalContext.Rule.Name+" - "+evalContext.Rule.Message)
+	if hostname, err := os.Hostname(); err == nil {
+		payloadJSON.Set("source", hostname)
+	}
+	payloadJSON.Set("severity", "critical")
+	payloadJSON.Set("timestamp", time.Now())
+	payloadJSON.Set("component", "Grafana")
+	payloadJSON.Set("custom_details", customData)
+
 	bodyJSON := simplejson.New()
-	bodyJSON.Set("service_key", this.Key)
-	bodyJSON.Set("description", evalContext.Rule.Name+" - "+evalContext.Rule.Message)
-	bodyJSON.Set("client", "Grafana")
-	bodyJSON.Set("details", customData)
-	bodyJSON.Set("event_type", eventType)
-	bodyJSON.Set("incident_key", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10))
+	bodyJSON.Set("routing_key", this.Key)
+	bodyJSON.Set("event_action", eventType)
+	bodyJSON.Set("dedup_key", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10))
+	bodyJSON.Set("payload", payloadJSON)
 
 	ruleUrl, err := evalContext.GetRuleUrl()
 	if err != nil {
 		this.log.Error("Failed get rule link", "error", err)
 		return err
 	}
+	links := make([]interface{}, 1)
+	linkJSON := simplejson.New()
+	linkJSON.Set("href", ruleUrl)
 	bodyJSON.Set("client_url", ruleUrl)
+	bodyJSON.Set("client", "Grafana")
+	links[0] = linkJSON
+	bodyJSON.Set("links", links)
 
 	if evalContext.ImagePublicUrl != "" {
 		contexts := make([]interface{}, 1)
 		imageJSON := simplejson.New()
-		imageJSON.Set("type", "image")
 		imageJSON.Set("src", evalContext.ImagePublicUrl)
 		contexts[0] = imageJSON
-		bodyJSON.Set("contexts", contexts)
+		bodyJSON.Set("images", contexts)
 	}
 
 	body, _ := bodyJSON.MarshalJSON()
@@ -115,6 +130,9 @@ func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error {
 		Url:        pagerdutyEventApiUrl,
 		Body:       string(body),
 		HttpMethod: "POST",
+		HttpHeader: map[string]string{
+			"Content-Type": "application/json",
+		},
 	}
 
 	if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {

+ 1 - 1
pkg/services/sqlstore/alert.go

@@ -132,7 +132,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 	return nil
 }
 
-func DeleteAlertDefinition(dashboardId int64, sess *DBSession) error {
+func deleteAlertDefinition(dashboardId int64, sess *DBSession) error {
 	alerts := make([]*m.Alert, 0)
 	sess.Where("dashboard_id = ?", dashboardId).Find(&alerts)
 

+ 1 - 1
pkg/services/sqlstore/dashboard.go

@@ -330,7 +330,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
 			}
 		}
 
-		if err := DeleteAlertDefinition(dashboard.Id, sess); err != nil {
+		if err := deleteAlertDefinition(dashboard.Id, sess); err != nil {
 			return nil
 		}
 

+ 15 - 0
public/app/core/specs/kbn.jest.ts

@@ -355,3 +355,18 @@ describe('volume', function() {
     expect(str).toBe('1000.0 m3');
   });
 });
+
+describe('hh:mm:ss', function() {
+  it('00:04:06', function() {
+    var str = kbn.valueFormats['dthms'](246, 1);
+    expect(str).toBe('00:04:06');
+  });
+  it('24:00:00', function() {
+    var str = kbn.valueFormats['dthms'](86400, 1);
+    expect(str).toBe('24:00:00');
+  });
+  it('6824413:53:20', function() {
+    var str = kbn.valueFormats['dthms'](24567890000, 1);
+    expect(str).toBe('6824413:53:20');
+  });
+});

+ 50 - 2
public/app/core/utils/kbn.ts

@@ -131,6 +131,17 @@ kbn.secondsToHms = function(seconds) {
   return 'less than a millisecond'; //'just now' //or other string you like;
 };
 
+kbn.secondsToHhmmss = function(seconds) {
+  var strings = [];
+  var numhours = Math.floor(seconds/3600);
+  var numminutes = Math.floor((seconds%3600)/60);
+  var numseconds = Math.floor((seconds%3600)%60);
+  numhours > 9 ? strings.push(''+numhours) : strings.push('0'+numhours);
+  numminutes > 9 ? strings.push(''+numminutes) : strings.push('0'+numminutes);
+  numseconds > 9 ? strings.push(''+numseconds) : strings.push('0'+numseconds);
+  return strings.join(':');
+};
+
 kbn.to_percent = function(nr, outof) {
   return Math.floor(nr / outof * 10000) / 100 + '%';
 };
@@ -378,7 +389,6 @@ kbn.valueFormats.short = kbn.formatBuilders.scaledUnits(1000, [
   ' Sept',
 ]);
 kbn.valueFormats.dB = kbn.formatBuilders.fixedUnit('dB');
-kbn.valueFormats.ppm = kbn.formatBuilders.fixedUnit('ppm');
 
 kbn.valueFormats.percent = function(size, decimals) {
   if (size === null) {
@@ -557,6 +567,7 @@ kbn.valueFormats.accG = kbn.formatBuilders.fixedUnit('g');
 kbn.valueFormats.litre = kbn.formatBuilders.decimalSIPrefix('L');
 kbn.valueFormats.mlitre = kbn.formatBuilders.decimalSIPrefix('L', -1);
 kbn.valueFormats.m3 = kbn.formatBuilders.fixedUnit('m3');
+kbn.valueFormats.Nm3 = kbn.formatBuilders.fixedUnit('Nm3');
 kbn.valueFormats.dm3 = kbn.formatBuilders.fixedUnit('dm3');
 kbn.valueFormats.gallons = kbn.formatBuilders.fixedUnit('gal');
 
@@ -582,6 +593,18 @@ kbn.valueFormats.radexpckg = kbn.formatBuilders.decimalSIPrefix('C/kg');
 kbn.valueFormats.radr = kbn.formatBuilders.decimalSIPrefix('R');
 kbn.valueFormats.radsvh = kbn.formatBuilders.decimalSIPrefix('Sv/h');
 
+// Concentration
+kbn.valueFormats.conppm = kbn.formatBuilders.fixedUnit('ppm');
+kbn.valueFormats.conppb = kbn.formatBuilders.fixedUnit('ppb');
+kbn.valueFormats.conngm3 = kbn.formatBuilders.fixedUnit('ng/m3');
+kbn.valueFormats.conngNm3 = kbn.formatBuilders.fixedUnit('ng/Nm3');
+kbn.valueFormats.conμgm3 = kbn.formatBuilders.fixedUnit('μg/m3');
+kbn.valueFormats.conμgNm3 = kbn.formatBuilders.fixedUnit('μg/Nm3');
+kbn.valueFormats.conmgm3 = kbn.formatBuilders.fixedUnit('mg/m3');
+kbn.valueFormats.conmgNm3 = kbn.formatBuilders.fixedUnit('mg/Nm3');
+kbn.valueFormats.congm3 = kbn.formatBuilders.fixedUnit('g/m3');
+kbn.valueFormats.congNm3 = kbn.formatBuilders.fixedUnit('g/Nm3');
+
 // Time
 kbn.valueFormats.hertz = kbn.formatBuilders.decimalSIPrefix('Hz');
 
@@ -783,6 +806,14 @@ kbn.valueFormats.dtdurations = function(size, decimals) {
   return kbn.toDuration(size, decimals, 'second');
 };
 
+kbn.valueFormats.dthms = function(size, decimals) {
+  return kbn.secondsToHhmmss(size);
+};
+
+kbn.valueFormats.timeticks = function(size, decimals, scaledDecimals) {
+  return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
+};
+
 kbn.valueFormats.dateTimeAsIso = function(epoch) {
   var time = moment(epoch);
 
@@ -817,7 +848,6 @@ kbn.getUnitFormats = function() {
         { text: 'percent (0-100)', value: 'percent' },
         { text: 'percent (0.0-1.0)', value: 'percentunit' },
         { text: 'Humidity (%H)', value: 'humidity' },
-        { text: 'ppm', value: 'ppm' },
         { text: 'decibel', value: 'dB' },
         { text: 'hexadecimal (0x)', value: 'hex0x' },
         { text: 'hexadecimal', value: 'hex' },
@@ -854,6 +884,8 @@ kbn.getUnitFormats = function() {
         { text: 'days (d)', value: 'd' },
         { text: 'duration (ms)', value: 'dtdurationms' },
         { text: 'duration (s)', value: 'dtdurations' },
+        { text: 'duration (hh:mm:ss)', value: 'dthms' },
+        { text: 'Timeticks (s/100)', value: 'timeticks' },
       ],
     },
     {
@@ -964,6 +996,7 @@ kbn.getUnitFormats = function() {
         { text: 'millilitre', value: 'mlitre' },
         { text: 'litre', value: 'litre' },
         { text: 'cubic metre', value: 'm3' },
+        { text: 'Normal cubic metre', value: 'Nm3' },
         { text: 'cubic decimetre', value: 'dm3' },
         { text: 'gallons', value: 'gallons' },
       ],
@@ -1061,6 +1094,21 @@ kbn.getUnitFormats = function() {
         { text: 'Sievert/hour (Sv/h)', value: 'radsvh' },
       ],
     },
+    {
+      text: 'concentration',
+      submenu: [
+        { text: 'parts-per-million (ppm)', value: 'conppm' },
+        { text: 'parts-per-billion (ppb)', value: 'conppb' },
+        { text: 'nanogram per cubic metre (ng/m3)', value: 'conngm3' },
+        { text: 'nanogram per normal cubic metre (ng/Nm3)', value: 'conngNm3' },
+        { text: 'microgram per cubic metre (μg/m3)', value: 'conμgm3' },
+        { text: 'microgram per normal cubic metre (μg/Nm3)', value: 'conμgNm3' },
+        { text: 'milligram per cubic metre (mg/m3)', value: 'conmgm3' },
+        { text: 'milligram per normal cubic metre (mg/Nm3)', value: 'conmgNm3' },
+        { text: 'gram per cubic metre (g/m3)', value: 'congm3' },
+        { text: 'gram per normal cubic metre (g/Nm3)', value: 'congNm3' },
+      ],
+    },
   ];
 };
 

+ 58 - 0
public/app/core/utils/ticks.ts

@@ -156,3 +156,61 @@ export function getFlotTickDecimals(data, axis) {
   const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10);
   return { tickDecimals, scaledDecimals };
 }
+
+/**
+ * Format timestamp similar to Grafana graph panel.
+ * @param ticks Number of ticks
+ * @param min Time from (in milliseconds)
+ * @param max Time to (in milliseconds)
+ */
+export function grafanaTimeFormat(ticks, min, max) {
+  if (min && max && ticks) {
+    let range = max - min;
+    let secPerTick = range / ticks / 1000;
+    let oneDay = 86400000;
+    let oneYear = 31536000000;
+
+    if (secPerTick <= 45) {
+      return '%H:%M:%S';
+    }
+    if (secPerTick <= 7200 || range <= oneDay) {
+      return '%H:%M';
+    }
+    if (secPerTick <= 80000) {
+      return '%m/%d %H:%M';
+    }
+    if (secPerTick <= 2419200 || range <= oneYear) {
+      return '%m/%d';
+    }
+    return '%Y-%m';
+  }
+
+  return '%H:%M';
+}
+
+/**
+ * Logarithm of value for arbitrary base.
+ */
+export function logp(value, base) {
+  return Math.log(value) / Math.log(base);
+}
+
+/**
+ * Get decimal precision of number (3.14 => 2)
+ */
+export function getPrecision(num: number): number {
+  let str = num.toString();
+  return getStringPrecision(str);
+}
+
+/**
+ * Get decimal precision of number stored as a string ("3.14" => 2)
+ */
+export function getStringPrecision(num: string): number {
+  let dot_index = num.indexOf('.');
+  if (dot_index === -1) {
+    return 0;
+  } else {
+    return num.length - dot_index - 1;
+  }
+}

+ 10 - 3
public/app/plugins/datasource/cloudwatch/datasource.ts

@@ -212,6 +212,7 @@ export default class CloudWatchDatasource {
     var region;
     var namespace;
     var metricName;
+    var filterJson;
 
     var regionQuery = query.match(/^regions\(\)/);
     if (regionQuery) {
@@ -237,14 +238,20 @@ export default class CloudWatchDatasource {
       return this.getDimensionKeys(namespace, region);
     }
 
-    var dimensionValuesQuery = query.match(/^dimension_values\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)\)/);
+    var dimensionValuesQuery = query.match(
+      /^dimension_values\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)(,\s?(.+))?\)/
+    );
     if (dimensionValuesQuery) {
       region = dimensionValuesQuery[1];
       namespace = dimensionValuesQuery[2];
       metricName = dimensionValuesQuery[3];
       var dimensionKey = dimensionValuesQuery[4];
+      filterJson = {};
+      if (dimensionValuesQuery[6]) {
+        filterJson = JSON.parse(this.templateSrv.replace(dimensionValuesQuery[6]));
+      }
 
-      return this.getDimensionValues(region, namespace, metricName, dimensionKey, {});
+      return this.getDimensionValues(region, namespace, metricName, dimensionKey, filterJson);
     }
 
     var ebsVolumeIdsQuery = query.match(/^ebs_volume_ids\(([^,]+?),\s?([^,]+?)\)/);
@@ -258,7 +265,7 @@ export default class CloudWatchDatasource {
     if (ec2InstanceAttributeQuery) {
       region = ec2InstanceAttributeQuery[1];
       var targetAttributeName = ec2InstanceAttributeQuery[2];
-      var filterJson = JSON.parse(this.templateSrv.replace(ec2InstanceAttributeQuery[3]));
+      filterJson = JSON.parse(this.templateSrv.replace(ec2InstanceAttributeQuery[3]));
       return this.getEc2InstanceAttribute(region, targetAttributeName, filterJson);
     }
 

+ 9 - 0
public/app/plugins/datasource/prometheus/config_ctrl.ts

@@ -0,0 +1,9 @@
+export class PrometheusConfigCtrl {
+  static templateUrl = 'public/app/plugins/datasource/prometheus/partials/config.html';
+  current: any;
+
+  /** @ngInject */
+  constructor($scope) {
+    this.current.jsonData.httpMethod = this.current.jsonData.httpMethod || 'GET';
+  }
+}

+ 59 - 151
public/app/plugins/datasource/prometheus/datasource.ts

@@ -1,9 +1,10 @@
 import _ from 'lodash';
 
+import $ from 'jquery';
 import kbn from 'app/core/utils/kbn';
 import * as dateMath from 'app/core/utils/datemath';
 import PrometheusMetricFindQuery from './metric_find_query';
-import TableModel from 'app/core/table_model';
+import { ResultTransformer } from './result_transformer';
 
 function prometheusSpecialRegexEscape(value) {
   return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\\\$&');
@@ -20,6 +21,8 @@ export class PrometheusDatasource {
   withCredentials: any;
   metricsNameCache: any;
   interval: string;
+  httpMethod: string;
+  resultTransformer: ResultTransformer;
 
   /** @ngInject */
   constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) {
@@ -32,14 +35,34 @@ export class PrometheusDatasource {
     this.basicAuth = instanceSettings.basicAuth;
     this.withCredentials = instanceSettings.withCredentials;
     this.interval = instanceSettings.jsonData.timeInterval || '15s';
+    this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
+    this.resultTransformer = new ResultTransformer(templateSrv);
   }
 
-  _request(method, url, requestId?) {
+  _request(method, url, data?, requestId?) {
     var options: any = {
       url: this.url + url,
       method: method,
       requestId: requestId,
     };
+    if (method === 'GET') {
+      if (!_.isEmpty(data)) {
+        options.url =
+          options.url +
+          '?' +
+          _.map(data, (v, k) => {
+            return encodeURIComponent(k) + '=' + encodeURIComponent(v);
+          }).join('&');
+      }
+    } else {
+      options.headers = {
+        'Content-Type': 'application/x-www-form-urlencoded',
+      };
+      options.transformRequest = data => {
+        return $.param(data);
+      };
+      options.data = data;
+    }
 
     if (this.basicAuth || this.withCredentials) {
       options.withCredentials = true;
@@ -73,7 +96,6 @@ export class PrometheusDatasource {
   }
 
   query(options) {
-    var self = this;
     var start = this.getPrometheusTime(options.range.from, false);
     var end = this.getPrometheusTime(options.range.to, true);
     var range = Math.ceil(end - start);
@@ -106,24 +128,24 @@ export class PrometheusDatasource {
     });
 
     return this.$q.all(allQueryPromise).then(responseList => {
-      var result = [];
+      let result = [];
 
       _.each(responseList, (response, index) => {
         if (response.status === 'error') {
           throw response.error;
         }
 
-        if (activeTargets[index].format === 'table') {
-          result.push(self.transformMetricDataToTable(response.data.data.result, responseList.length, index));
-        } else {
-          for (let metricData of response.data.data.result) {
-            if (response.data.data.resultType === 'matrix') {
-              result.push(self.transformMetricData(metricData, activeTargets[index], start, end, queries[index].step));
-            } else if (response.data.data.resultType === 'vector') {
-              result.push(self.transformInstantMetricData(metricData, activeTargets[index]));
-            }
-          }
-        }
+        let transformerOptions = {
+          format: activeTargets[index].format,
+          step: queries[index].step,
+          legendFormat: activeTargets[index].legendFormat,
+          start: start,
+          end: end,
+          responseListLength: responseList.length,
+          responseIndex: index,
+        };
+
+        this.resultTransformer.transform(result, response, transformerOptions);
       });
 
       return { data: result };
@@ -173,21 +195,23 @@ export class PrometheusDatasource {
       throw { message: 'Invalid time range' };
     }
 
-    var url =
-      '/api/v1/query_range?query=' +
-      encodeURIComponent(query.expr) +
-      '&start=' +
-      start +
-      '&end=' +
-      end +
-      '&step=' +
-      query.step;
-    return this._request('GET', url, query.requestId);
+    var url = '/api/v1/query_range';
+    var data = {
+      query: query.expr,
+      start: start,
+      end: end,
+      step: query.step,
+    };
+    return this._request(this.httpMethod, url, data, query.requestId);
   }
 
   performInstantQuery(query, time) {
-    var url = '/api/v1/query?query=' + encodeURIComponent(query.expr) + '&time=' + time;
-    return this._request('GET', url, query.requestId);
+    var url = '/api/v1/query';
+    var data = {
+      query: query.expr,
+      time: time,
+    };
+    return this._request(this.httpMethod, url, data, query.requestId);
   }
 
   performSuggestQuery(query, cache = false) {
@@ -264,9 +288,9 @@ export class PrometheusDatasource {
             var event = {
               annotation: annotation,
               time: Math.floor(parseFloat(value[0])) * 1000,
-              title: self.renderTemplate(titleFormat, series.metric),
+              title: self.resultTransformer.renderTemplate(titleFormat, series.metric),
               tags: tags,
-              text: self.renderTemplate(textFormat, series.metric),
+              text: self.resultTransformer.renderTemplate(textFormat, series.metric),
             };
 
             eventList.push(event);
@@ -279,132 +303,16 @@ export class PrometheusDatasource {
   }
 
   testDatasource() {
-    return this.metricFindQuery('metrics(.*)').then(function() {
-      return { status: 'success', message: 'Data source is working' };
-    });
-  }
-
-  transformMetricData(md, options, start, end, step) {
-    var dps = [],
-      metricLabel = null;
-
-    metricLabel = this.createMetricLabel(md.metric, options);
-
-    var stepMs = step * 1000;
-    var baseTimestamp = start * 1000;
-    for (let value of md.values) {
-      var dp_value = parseFloat(value[1]);
-      if (_.isNaN(dp_value)) {
-        dp_value = null;
-      }
-
-      var timestamp = parseFloat(value[0]) * 1000;
-      for (let t = baseTimestamp; t < timestamp; t += stepMs) {
-        dps.push([null, t]);
-      }
-      baseTimestamp = timestamp + stepMs;
-      dps.push([dp_value, timestamp]);
-    }
-
-    var endTimestamp = end * 1000;
-    for (let t = baseTimestamp; t <= endTimestamp; t += stepMs) {
-      dps.push([null, t]);
-    }
-
-    return { target: metricLabel, datapoints: dps };
-  }
-
-  transformMetricDataToTable(md, resultCount: number, resultIndex: number) {
-    var table = new TableModel();
-    var i, j;
-    var metricLabels = {};
-
-    if (md.length === 0) {
-      return table;
-    }
-
-    // Collect all labels across all metrics
-    _.each(md, function(series) {
-      for (var label in series.metric) {
-        if (!metricLabels.hasOwnProperty(label)) {
-          metricLabels[label] = 1;
-        }
-      }
-    });
-
-    // Sort metric labels, create columns for them and record their index
-    var sortedLabels = _.keys(metricLabels).sort();
-    table.columns.push({ text: 'Time', type: 'time' });
-    _.each(sortedLabels, function(label, labelIndex) {
-      metricLabels[label] = labelIndex + 1;
-      table.columns.push({ text: label });
-    });
-    let valueText = resultCount > 1 ? `Value #${String.fromCharCode(65 + resultIndex)}` : 'Value';
-    table.columns.push({ text: valueText });
-
-    // Populate rows, set value to empty string when label not present.
-    _.each(md, function(series) {
-      if (series.value) {
-        series.values = [series.value];
-      }
-      if (series.values) {
-        for (i = 0; i < series.values.length; i++) {
-          var values = series.values[i];
-          var reordered: any = [values[0] * 1000];
-          if (series.metric) {
-            for (j = 0; j < sortedLabels.length; j++) {
-              var label = sortedLabels[j];
-              if (series.metric.hasOwnProperty(label)) {
-                reordered.push(series.metric[label]);
-              } else {
-                reordered.push('');
-              }
-            }
-          }
-          reordered.push(parseFloat(values[1]));
-          table.rows.push(reordered);
-        }
-      }
-    });
-
-    return table;
-  }
-
-  transformInstantMetricData(md, options) {
-    var dps = [],
-      metricLabel = null;
-    metricLabel = this.createMetricLabel(md.metric, options);
-    dps.push([parseFloat(md.value[1]), md.value[0] * 1000]);
-    return { target: metricLabel, datapoints: dps };
-  }
-
-  createMetricLabel(labelData, options) {
-    if (_.isUndefined(options) || _.isEmpty(options.legendFormat)) {
-      return this.getOriginalMetricName(labelData);
-    }
-
-    return this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData) || '{}';
-  }
-
-  renderTemplate(aliasPattern, aliasData) {
-    var aliasRegex = /\{\{\s*(.+?)\s*\}\}/g;
-    return aliasPattern.replace(aliasRegex, function(match, g1) {
-      if (aliasData[g1]) {
-        return aliasData[g1];
+    let now = new Date().getTime();
+    return this.performInstantQuery({ expr: '1+1' }, now / 1000).then(response => {
+      if (response.data.status === 'success') {
+        return { status: 'success', message: 'Data source is working' };
+      } else {
+        return { status: 'error', message: response.error };
       }
-      return g1;
     });
   }
 
-  getOriginalMetricName(labelData) {
-    var metricName = labelData.__name__ || '';
-    delete labelData.__name__;
-    var labelPart = _.map(_.toPairs(labelData), function(label) {
-      return label[0] + '="' + label[1] + '"';
-    }).join(',');
-    return metricName + '{' + labelPart + '}';
-  }
-
   getPrometheusTime(date, roundUp) {
     if (_.isString(date)) {
       date = dateMath.parse(date, roundUp);

+ 1 - 4
public/app/plugins/datasource/prometheus/module.ts

@@ -1,9 +1,6 @@
 import { PrometheusDatasource } from './datasource';
 import { PrometheusQueryCtrl } from './query_ctrl';
-
-class PrometheusConfigCtrl {
-  static templateUrl = 'partials/config.html';
-}
+import { PrometheusConfigCtrl } from './config_ctrl';
 
 class PrometheusAnnotationsQueryCtrl {
   static templateUrl = 'partials/annotations.editor.html';

+ 14 - 4
public/app/plugins/datasource/prometheus/partials/config.html

@@ -4,13 +4,23 @@
 <div class="gf-form-group">
 	<div class="gf-form-inline">
 		<div class="gf-form">
-			<span class="gf-form-label">Scrape interval</span>
-			<input type="text" class="gf-form-input width-6" ng-model="ctrl.current.jsonData.timeInterval" spellcheck='false' placeholder="15s"></input>
+			<span class="gf-form-label width-8">Scrape interval</span>
+			<input type="text" class="gf-form-input width-8" ng-model="ctrl.current.jsonData.timeInterval" spellcheck='false' placeholder="15s"></input>
 			<info-popover mode="right-absolute">
-                Set this to your global scrape interval defined in your Prometheus config file. This will be used as a lower limit for 
+                Set this to your global scrape interval defined in your Prometheus config file. This will be used as a lower limit for
                 the Prometheus step query parameter.
 			</info-popover>
 		</div>
-	</div>
+  </div>
+
+  <div class="gf-form">
+    <label class="gf-form-label width-8">HTTP Method</label>
+    <div class="gf-form-select-wrapper width-8 gf-form-select-wrapper--has-help-icon">
+      <select class="gf-form-input" ng-model="ctrl.current.jsonData.httpMethod" ng-options="method for method in ['GET', 'POST']"></select>
+      <info-popover mode="right-absolute">
+        Specify the HTTP Method to query Prometheus. (POST is only available in Prometheus >= v2.1.0)
+      </info-popover>
+    </div>
+  </div>
 </div>
 

+ 8 - 1
public/app/plugins/datasource/prometheus/query_ctrl.ts

@@ -31,7 +31,11 @@ class PrometheusQueryCtrl extends QueryCtrl {
       return { factor: f, label: '1/' + f };
     });
 
-    this.formats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }];
+    this.formats = [
+      { text: 'Time series', value: 'time_series' },
+      { text: 'Table', value: 'table' },
+      { text: 'Heatmap', value: 'heatmap' },
+    ];
 
     this.instant = false;
 
@@ -45,7 +49,10 @@ class PrometheusQueryCtrl extends QueryCtrl {
   getDefaultFormat() {
     if (this.panelCtrl.panel.type === 'table') {
       return 'table';
+    } else if (this.panelCtrl.panel.type === 'heatmap') {
+      return 'heatmap';
     }
+
     return 'time_series';
   }
 

+ 199 - 0
public/app/plugins/datasource/prometheus/result_transformer.ts

@@ -0,0 +1,199 @@
+import _ from 'lodash';
+import TableModel from 'app/core/table_model';
+
+export class ResultTransformer {
+  constructor(private templateSrv) {}
+
+  transform(result: any, response: any, options: any) {
+    let prometheusResult = response.data.data.result;
+
+    if (options.format === 'table') {
+      result.push(this.transformMetricDataToTable(prometheusResult, options.responseListLength, options.responseIndex));
+    } else if (options.format === 'heatmap') {
+      let seriesList = [];
+      prometheusResult.sort(sortSeriesByLabel);
+      for (let metricData of prometheusResult) {
+        seriesList.push(this.transformMetricData(metricData, options, options.start, options.end));
+      }
+      seriesList = this.transformToHistogramOverTime(seriesList);
+      result.push(...seriesList);
+    } else {
+      for (let metricData of prometheusResult) {
+        if (response.data.data.resultType === 'matrix') {
+          result.push(this.transformMetricData(metricData, options, options.start, options.end));
+        } else if (response.data.data.resultType === 'vector') {
+          result.push(this.transformInstantMetricData(metricData, options));
+        }
+      }
+    }
+  }
+
+  transformMetricData(md, options, start, end) {
+    let dps = [],
+      metricLabel = null;
+
+    metricLabel = this.createMetricLabel(md.metric, options);
+
+    const stepMs = parseInt(options.step) * 1000;
+    let baseTimestamp = start * 1000;
+    for (let value of md.values) {
+      let dp_value = parseFloat(value[1]);
+      if (_.isNaN(dp_value)) {
+        dp_value = null;
+      }
+
+      const timestamp = parseFloat(value[0]) * 1000;
+      for (let t = baseTimestamp; t < timestamp; t += stepMs) {
+        dps.push([null, t]);
+      }
+      baseTimestamp = timestamp + stepMs;
+      dps.push([dp_value, timestamp]);
+    }
+
+    const endTimestamp = end * 1000;
+    for (let t = baseTimestamp; t <= endTimestamp; t += stepMs) {
+      dps.push([null, t]);
+    }
+
+    return { target: metricLabel, datapoints: dps };
+  }
+
+  transformMetricDataToTable(md, resultCount: number, resultIndex: number) {
+    var table = new TableModel();
+    var i, j;
+    var metricLabels = {};
+
+    if (md.length === 0) {
+      return table;
+    }
+
+    // Collect all labels across all metrics
+    _.each(md, function(series) {
+      for (var label in series.metric) {
+        if (!metricLabels.hasOwnProperty(label)) {
+          metricLabels[label] = 1;
+        }
+      }
+    });
+
+    // Sort metric labels, create columns for them and record their index
+    var sortedLabels = _.keys(metricLabels).sort();
+    table.columns.push({ text: 'Time', type: 'time' });
+    _.each(sortedLabels, function(label, labelIndex) {
+      metricLabels[label] = labelIndex + 1;
+      table.columns.push({ text: label });
+    });
+    let valueText = resultCount > 1 ? `Value #${String.fromCharCode(65 + resultIndex)}` : 'Value';
+    table.columns.push({ text: valueText });
+
+    // Populate rows, set value to empty string when label not present.
+    _.each(md, function(series) {
+      if (series.value) {
+        series.values = [series.value];
+      }
+      if (series.values) {
+        for (i = 0; i < series.values.length; i++) {
+          var values = series.values[i];
+          var reordered: any = [values[0] * 1000];
+          if (series.metric) {
+            for (j = 0; j < sortedLabels.length; j++) {
+              var label = sortedLabels[j];
+              if (series.metric.hasOwnProperty(label)) {
+                reordered.push(series.metric[label]);
+              } else {
+                reordered.push('');
+              }
+            }
+          }
+          reordered.push(parseFloat(values[1]));
+          table.rows.push(reordered);
+        }
+      }
+    });
+
+    return table;
+  }
+
+  transformInstantMetricData(md, options) {
+    var dps = [],
+      metricLabel = null;
+    metricLabel = this.createMetricLabel(md.metric, options);
+    dps.push([parseFloat(md.value[1]), md.value[0] * 1000]);
+    return { target: metricLabel, datapoints: dps };
+  }
+
+  createMetricLabel(labelData, options) {
+    if (_.isUndefined(options) || _.isEmpty(options.legendFormat)) {
+      return this.getOriginalMetricName(labelData);
+    }
+
+    return this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData) || '{}';
+  }
+
+  renderTemplate(aliasPattern, aliasData) {
+    var aliasRegex = /\{\{\s*(.+?)\s*\}\}/g;
+    return aliasPattern.replace(aliasRegex, function(match, g1) {
+      if (aliasData[g1]) {
+        return aliasData[g1];
+      }
+      return g1;
+    });
+  }
+
+  getOriginalMetricName(labelData) {
+    var metricName = labelData.__name__ || '';
+    delete labelData.__name__;
+    var labelPart = _.map(_.toPairs(labelData), function(label) {
+      return label[0] + '="' + label[1] + '"';
+    }).join(',');
+    return metricName + '{' + labelPart + '}';
+  }
+
+  transformToHistogramOverTime(seriesList) {
+    /*      t1 = timestamp1, t2 = timestamp2 etc.
+            t1  t2  t3          t1  t2  t3
+    le10    10  10  0     =>    10  10  0
+    le20    20  10  30    =>    10  0   30
+    le30    30  10  35    =>    10  0   5
+    */
+    for (let i = seriesList.length - 1; i > 0; i--) {
+      let topSeries = seriesList[i].datapoints;
+      let bottomSeries = seriesList[i - 1].datapoints;
+      for (let j = 0; j < topSeries.length; j++) {
+        topSeries[j][0] -= bottomSeries[j][0];
+      }
+    }
+
+    return seriesList;
+  }
+}
+
+function sortSeriesByLabel(s1, s2): number {
+  let le1, le2;
+
+  try {
+    // fail if not integer. might happen with bad queries
+    le1 = parseHistogramLabel(s1.metric.le);
+    le2 = parseHistogramLabel(s2.metric.le);
+  } catch (err) {
+    console.log(err);
+    return 0;
+  }
+
+  if (le1 > le2) {
+    return 1;
+  }
+
+  if (le1 < le2) {
+    return -1;
+  }
+
+  return 0;
+}
+
+function parseHistogramLabel(le: string): number {
+  if (le === '+Inf') {
+    return +Infinity;
+  }
+  return Number(le);
+}

+ 104 - 0
public/app/plugins/datasource/prometheus/specs/datasource.jest.ts

@@ -0,0 +1,104 @@
+import _ from 'lodash';
+import moment from 'moment';
+import q from 'q';
+import { PrometheusDatasource } from '../datasource';
+
+describe('PrometheusDatasource', () => {
+  let ctx: any = {};
+  let instanceSettings = {
+    url: 'proxied',
+    directUrl: 'direct',
+    user: 'test',
+    password: 'mupp',
+    jsonData: {},
+  };
+
+  ctx.backendSrvMock = {};
+  ctx.templateSrvMock = {
+    replace: a => a,
+  };
+  ctx.timeSrvMock = {};
+
+  beforeEach(() => {
+    ctx.ds = new PrometheusDatasource(instanceSettings, q, ctx.backendSrvMock, ctx.templateSrvMock, ctx.timeSrvMock);
+  });
+
+  describe('When converting prometheus histogram to heatmap format', () => {
+    beforeEach(() => {
+      ctx.query = {
+        range: { from: moment(1443454528000), to: moment(1443454528000) },
+        targets: [{ expr: 'test{job="testjob"}', format: 'heatmap', legendFormat: '{{le}}' }],
+        interval: '60s',
+      };
+    });
+
+    it('should convert cumullative histogram to ordinary', () => {
+      const resultMock = [
+        {
+          metric: { __name__: 'metric', job: 'testjob', le: '10' },
+          values: [[1443454528.0, '10'], [1443454528.0, '10']],
+        },
+        {
+          metric: { __name__: 'metric', job: 'testjob', le: '20' },
+          values: [[1443454528.0, '20'], [1443454528.0, '10']],
+        },
+        {
+          metric: { __name__: 'metric', job: 'testjob', le: '30' },
+          values: [[1443454528.0, '25'], [1443454528.0, '10']],
+        },
+      ];
+      const responseMock = { data: { data: { result: resultMock } } };
+
+      const expected = [
+        {
+          target: '10',
+          datapoints: [[10, 1443454528000], [10, 1443454528000]],
+        },
+        {
+          target: '20',
+          datapoints: [[10, 1443454528000], [0, 1443454528000]],
+        },
+        {
+          target: '30',
+          datapoints: [[5, 1443454528000], [0, 1443454528000]],
+        },
+      ];
+
+      ctx.ds.performTimeSeriesQuery = jest.fn().mockReturnValue(responseMock);
+      return ctx.ds.query(ctx.query).then(result => {
+        let results = result.data;
+        return expect(results).toEqual(expected);
+      });
+    });
+
+    it('should sort series by label value', () => {
+      const resultMock = [
+        {
+          metric: { __name__: 'metric', job: 'testjob', le: '2' },
+          values: [[1443454528.0, '10'], [1443454528.0, '10']],
+        },
+        {
+          metric: { __name__: 'metric', job: 'testjob', le: '4' },
+          values: [[1443454528.0, '20'], [1443454528.0, '10']],
+        },
+        {
+          metric: { __name__: 'metric', job: 'testjob', le: '+Inf' },
+          values: [[1443454528.0, '25'], [1443454528.0, '10']],
+        },
+        {
+          metric: { __name__: 'metric', job: 'testjob', le: '1' },
+          values: [[1443454528.0, '25'], [1443454528.0, '10']],
+        },
+      ];
+      const responseMock = { data: { data: { result: resultMock } } };
+
+      const expected = ['1', '2', '4', '+Inf'];
+
+      ctx.ds.performTimeSeriesQuery = jest.fn().mockReturnValue(responseMock);
+      return ctx.ds.query(ctx.query).then(result => {
+        let seriesLabels = _.map(result.data, 'target');
+        return expect(seriesLabels).toEqual(expected);
+      });
+    });
+  });
+});

+ 70 - 50
public/app/plugins/datasource/prometheus/specs/datasource_specs.ts

@@ -1,5 +1,6 @@
 import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
 import moment from 'moment';
+import $ from 'jquery';
 import helpers from 'test/specs/helpers';
 import { PrometheusDatasource } from '../datasource';
 
@@ -10,7 +11,7 @@ describe('PrometheusDatasource', function() {
     directUrl: 'direct',
     user: 'test',
     password: 'mupp',
-    jsonData: {},
+    jsonData: { httpMethod: 'GET' },
   };
 
   beforeEach(angularMocks.module('grafana.core'));
@@ -223,43 +224,6 @@ describe('PrometheusDatasource', function() {
       expect(results[0].time).to.be(1443454528 * 1000);
     });
   });
-  describe('When resultFormat is table', function() {
-    var response = {
-      status: 'success',
-      data: {
-        resultType: 'matrix',
-        result: [
-          {
-            metric: { __name__: 'test', job: 'testjob' },
-            values: [[1443454528, '3846']],
-          },
-          {
-            metric: {
-              __name__: 'test',
-              instance: 'localhost:8080',
-              job: 'otherjob',
-            },
-            values: [[1443454529, '3847']],
-          },
-        ],
-      },
-    };
-    it('should return table model', function() {
-      var table = ctx.ds.transformMetricDataToTable(response.data.result);
-      expect(table.type).to.be('table');
-      expect(table.rows).to.eql([
-        [1443454528000, 'test', '', 'testjob', 3846],
-        [1443454529000, 'test', 'localhost:8080', 'otherjob', 3847],
-      ]);
-      expect(table.columns).to.eql([
-        { text: 'Time', type: 'time' },
-        { text: '__name__' },
-        { text: 'instance' },
-        { text: 'job' },
-        { text: 'Value' },
-      ]);
-    });
-  });
 
   describe('When resultFormat is table and instant = true', function() {
     var results;
@@ -293,19 +257,8 @@ describe('PrometheusDatasource', function() {
     it('should return result', () => {
       expect(results).not.to.be(null);
     });
-
-    it('should return table model', function() {
-      var table = ctx.ds.transformMetricDataToTable(response.data.result);
-      expect(table.type).to.be('table');
-      expect(table.rows).to.eql([[1443454528000, 'test', 'testjob', 3846]]);
-      expect(table.columns).to.eql([
-        { text: 'Time', type: 'time' },
-        { text: '__name__' },
-        { text: 'job' },
-        { text: 'Value' },
-      ]);
-    });
   });
+
   describe('The "step" query parameter', function() {
     var response = {
       status: 'success',
@@ -652,3 +605,70 @@ describe('PrometheusDatasource', function() {
     });
   });
 });
+
+describe('PrometheusDatasource for POST', function() {
+  var ctx = new helpers.ServiceTestContext();
+  var instanceSettings = {
+    url: 'proxied',
+    directUrl: 'direct',
+    user: 'test',
+    password: 'mupp',
+    jsonData: { httpMethod: 'POST' },
+  };
+
+  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;
+      ctx.$rootScope = $rootScope;
+      ctx.ds = $injector.instantiate(PrometheusDatasource, { instanceSettings: instanceSettings });
+      $httpBackend.when('GET', /\.html$/).respond('');
+    })
+  );
+
+  describe('When querying prometheus with one target using query editor target spec', function() {
+    var results;
+    var urlExpected = 'proxied/api/v1/query_range';
+    var dataExpected = $.param({
+      query: 'test{job="testjob"}',
+      start: 1443438675,
+      end: 1443460275,
+      step: 60,
+    });
+    var query = {
+      range: { from: moment(1443438674760), to: moment(1443460274760) },
+      targets: [{ expr: 'test{job="testjob"}', format: 'time_series' }],
+      interval: '60s',
+    };
+    var response = {
+      status: 'success',
+      data: {
+        resultType: 'matrix',
+        result: [
+          {
+            metric: { __name__: 'test', job: 'testjob' },
+            values: [[1443454528, '3846']],
+          },
+        ],
+      },
+    };
+    beforeEach(function() {
+      ctx.$httpBackend.expectPOST(urlExpected, dataExpected).respond(response);
+      ctx.ds.query(query).then(function(data) {
+        results = data;
+      });
+      ctx.$httpBackend.flush();
+    });
+    it('should generate the correct query', function() {
+      ctx.$httpBackend.verifyNoOutstandingExpectation();
+    });
+    it('should return series list', function() {
+      expect(results.data.length).to.be(1);
+      expect(results.data[0].target).to.be('test{job="testjob"}');
+    });
+  });
+});

+ 1 - 1
public/app/plugins/datasource/prometheus/specs/metric_find_query_specs.ts

@@ -12,7 +12,7 @@ describe('PrometheusMetricFindQuery', function() {
     directUrl: 'direct',
     user: 'test',
     password: 'mupp',
-    jsonData: {},
+    jsonData: { httpMethod: 'GET' },
   };
 
   beforeEach(angularMocks.module('grafana.core'));

+ 118 - 0
public/app/plugins/datasource/prometheus/specs/result_transformer.jest.ts

@@ -0,0 +1,118 @@
+import { ResultTransformer } from '../result_transformer';
+
+describe('Prometheus Result Transformer', () => {
+  let ctx: any = {};
+
+  beforeEach(() => {
+    ctx.templateSrv = {
+      replace: str => str,
+    };
+    ctx.resultTransformer = new ResultTransformer(ctx.templateSrv);
+  });
+
+  describe('When resultFormat is table', () => {
+    var response = {
+      status: 'success',
+      data: {
+        resultType: 'matrix',
+        result: [
+          {
+            metric: { __name__: 'test', job: 'testjob' },
+            values: [[1443454528, '3846']],
+          },
+          {
+            metric: {
+              __name__: 'test',
+              instance: 'localhost:8080',
+              job: 'otherjob',
+            },
+            values: [[1443454529, '3847']],
+          },
+        ],
+      },
+    };
+
+    it('should return table model', () => {
+      var table = ctx.resultTransformer.transformMetricDataToTable(response.data.result);
+      expect(table.type).toBe('table');
+      expect(table.rows).toEqual([
+        [1443454528000, 'test', '', 'testjob', 3846],
+        [1443454529000, 'test', 'localhost:8080', 'otherjob', 3847],
+      ]);
+      expect(table.columns).toEqual([
+        { text: 'Time', type: 'time' },
+        { text: '__name__' },
+        { text: 'instance' },
+        { text: 'job' },
+        { text: 'Value' },
+      ]);
+    });
+  });
+
+  describe('When resultFormat is table and instant = true', () => {
+    var response = {
+      status: 'success',
+      data: {
+        resultType: 'vector',
+        result: [
+          {
+            metric: { __name__: 'test', job: 'testjob' },
+            value: [1443454528, '3846'],
+          },
+        ],
+      },
+    };
+
+    it('should return table model', () => {
+      var table = ctx.resultTransformer.transformMetricDataToTable(response.data.result);
+      expect(table.type).toBe('table');
+      expect(table.rows).toEqual([[1443454528000, 'test', 'testjob', 3846]]);
+      expect(table.columns).toEqual([
+        { text: 'Time', type: 'time' },
+        { text: '__name__' },
+        { text: 'job' },
+        { text: 'Value' },
+      ]);
+    });
+  });
+
+  describe('When resultFormat is heatmap', () => {
+    var response = {
+      status: 'success',
+      data: {
+        resultType: 'matrix',
+        result: [
+          {
+            metric: { __name__: 'test', job: 'testjob', le: '1' },
+            values: [[1445000010, '10'], [1445000020, '10'], [1445000030, '0']],
+          },
+          {
+            metric: { __name__: 'test', job: 'testjob', le: '2' },
+            values: [[1445000010, '20'], [1445000020, '10'], [1445000030, '30']],
+          },
+          {
+            metric: { __name__: 'test', job: 'testjob', le: '3' },
+            values: [[1445000010, '30'], [1445000020, '10'], [1445000030, '40']],
+          },
+        ],
+      },
+    };
+
+    it('should convert cumulative histogram to regular', () => {
+      let result = [];
+      let options = {
+        format: 'heatmap',
+        start: 1445000010,
+        end: 1445000030,
+        legendFormat: '{{le}}',
+      };
+
+      ctx.resultTransformer.transform(result, { data: response }, options);
+      expect(result).toEqual([
+        { target: '1', datapoints: [[10, 1445000010000], [10, 1445000020000], [0, 1445000030000]] },
+        { target: '2', datapoints: [[10, 1445000010000], [0, 1445000020000], [30, 1445000030000]] },
+        { target: '3', datapoints: [[10, 1445000010000], [0, 1445000020000], [10, 1445000030000]] },
+      ]);
+    });
+  });
+});

+ 11 - 6
public/app/plugins/panel/graph/data_processor.ts

@@ -29,12 +29,17 @@ export class DataProcessor {
         });
       }
       case 'histogram': {
-        let histogramDataList = [
-          {
-            target: 'count',
-            datapoints: _.concat([], _.flatten(_.map(options.dataList, 'datapoints'))),
-          },
-        ];
+        let histogramDataList;
+        if (this.panel.stack) {
+          histogramDataList = options.dataList;
+        } else {
+          histogramDataList = [
+            {
+              target: 'count',
+              datapoints: _.concat([], _.flatten(_.map(options.dataList, 'datapoints'))),
+            },
+          ];
+        }
         return histogramDataList.map((item, index) => {
           return this.timeSeriesHandler(item, index, options);
         });

+ 10 - 6
public/app/plugins/panel/graph/graph.ts

@@ -17,7 +17,7 @@ import { appEvents, coreModule, updateLegendValues } from 'app/core/core';
 import GraphTooltip from './graph_tooltip';
 import { ThresholdManager } from './threshold_manager';
 import { EventManager } from 'app/features/annotations/all';
-import { convertValuesToHistogram, getSeriesValues } from './histogram';
+import { convertToHistogramData } from './histogram';
 import config from 'app/core/config';
 
 /** @ngInject **/
@@ -236,16 +236,14 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) {
           }
           case 'histogram': {
             let bucketSize: number;
-            let values = getSeriesValues(data);
 
-            if (data.length && values.length) {
+            if (data.length) {
               let histMin = _.min(_.map(data, s => s.stats.min));
               let histMax = _.max(_.map(data, s => s.stats.max));
               let ticks = panel.xaxis.buckets || panelWidth / 50;
               bucketSize = tickStep(histMin, histMax, ticks);
-              let histogram = convertValuesToHistogram(values, bucketSize);
-              data[0].data = histogram;
               options.series.bars.barWidth = bucketSize * 0.8;
+              data = convertToHistogramData(data, bucketSize, ctrl.hiddenSeries, histMin, histMax);
             } else {
               bucketSize = 0;
             }
@@ -413,7 +411,13 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) {
         let defaultTicks = panelWidth / 50;
 
         if (data.length && bucketSize) {
-          ticks = _.map(data[0].data, point => point[0]);
+          let tick_values = [];
+          for (let d of data) {
+            for (let point of d.data) {
+              tick_values[point[0]] = true;
+            }
+          }
+          ticks = Object.keys(tick_values).map(v => Number(v));
           min = _.min(ticks);
           max = _.max(ticks);
 

+ 37 - 6
public/app/plugins/panel/graph/histogram.ts

@@ -29,16 +29,22 @@ export function getSeriesValues(dataList: TimeSeries[]): number[] {
  * @param values
  * @param bucketSize
  */
-export function convertValuesToHistogram(values: number[], bucketSize: number): any[] {
+export function convertValuesToHistogram(values: number[], bucketSize: number, min: number, max: number): any[] {
   let histogram = {};
 
+  let minBound = getBucketBound(min, bucketSize);
+  let maxBound = getBucketBound(max, bucketSize);
+  let bound = minBound;
+  let n = 0;
+  while (bound <= maxBound) {
+    histogram[bound] = 0;
+    bound = minBound + bucketSize * n;
+    n++;
+  }
+
   for (let i = 0; i < values.length; i++) {
     let bound = getBucketBound(values[i], bucketSize);
-    if (histogram[bound]) {
-      histogram[bound] = histogram[bound] + 1;
-    } else {
-      histogram[bound] = 1;
-    }
+    histogram[bound] = histogram[bound] + 1;
   }
 
   let histogam_series = _.map(histogram, (count, bound) => {
@@ -49,6 +55,31 @@ export function convertValuesToHistogram(values: number[], bucketSize: number):
   return _.sortBy(histogam_series, point => point[0]);
 }
 
+/**
+ * Convert series into array of histogram data.
+ * @param data Array of series
+ * @param bucketSize
+ */
+export function convertToHistogramData(
+  data: any,
+  bucketSize: number,
+  hiddenSeries: any,
+  min: number,
+  max: number
+): any[] {
+  return data.map(series => {
+    let values = getSeriesValues([series]);
+    series.histogram = true;
+    if (!hiddenSeries[series.alias]) {
+      let histogram = convertValuesToHistogram(values, bucketSize, min, max);
+      series.data = histogram;
+    } else {
+      series.data = [];
+    }
+    return series;
+  });
+}
+
 function getBucketBound(value: number, bucketSize: number): number {
   return Math.floor(value / bucketSize) * bucketSize;
 }

+ 44 - 0
public/app/plugins/panel/graph/specs/graph_specs.ts

@@ -407,4 +407,48 @@ describe('grafanaGraph', function() {
     },
     10
   );
+
+  graphScenario('when graph is histogram, and enable stack', function(ctx) {
+    ctx.setup(function(ctrl, data) {
+      ctrl.panel.xaxis.mode = 'histogram';
+      ctrl.panel.stack = true;
+      ctrl.hiddenSeries = {};
+      data[0] = new TimeSeries({
+        datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
+        alias: 'series1',
+      });
+      data[1] = new TimeSeries({
+        datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
+        alias: 'series2',
+      });
+    });
+
+    it('should calculate correct histogram', function() {
+      expect(ctx.plotData[0].data[0][0]).to.be(100);
+      expect(ctx.plotData[0].data[0][1]).to.be(2);
+      expect(ctx.plotData[1].data[0][0]).to.be(100);
+      expect(ctx.plotData[1].data[0][1]).to.be(2);
+    });
+  });
+
+  graphScenario('when graph is histogram, and some series are hidden', function(ctx) {
+    ctx.setup(function(ctrl, data) {
+      ctrl.panel.xaxis.mode = 'histogram';
+      ctrl.panel.stack = false;
+      ctrl.hiddenSeries = { series2: true };
+      data[0] = new TimeSeries({
+        datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
+        alias: 'series1',
+      });
+      data[1] = new TimeSeries({
+        datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
+        alias: 'series2',
+      });
+    });
+
+    it('should calculate correct histogram', function() {
+      expect(ctx.plotData[0].data[0][0]).to.be(100);
+      expect(ctx.plotData[0].data[0][1]).to.be(2);
+    });
+  });
 });

+ 3 - 3
public/app/plugins/panel/graph/specs/histogram.jest.ts

@@ -13,15 +13,15 @@ describe('Graph Histogam Converter', function() {
       bucketSize = 10;
       let expected = [[0, 2], [10, 3], [20, 2]];
 
-      let histogram = convertValuesToHistogram(values, bucketSize);
+      let histogram = convertValuesToHistogram(values, bucketSize, 1, 29);
       expect(histogram).toMatchObject(expected);
     });
 
     it('Should not add empty buckets', () => {
       bucketSize = 5;
-      let expected = [[0, 2], [10, 2], [15, 1], [20, 1], [25, 1]];
+      let expected = [[0, 2], [5, 0], [10, 2], [15, 1], [20, 1], [25, 1]];
 
-      let histogram = convertValuesToHistogram(values, bucketSize);
+      let histogram = convertValuesToHistogram(values, bucketSize, 1, 29);
       expect(histogram).toMatchObject(expected);
     });
   });

+ 1 - 1
public/app/plugins/panel/graph/tab_display.html

@@ -71,7 +71,7 @@
 
 		<div class="section gf-form-group">
 			<h5 class="section-heading">Stacking & Null value</h5>
-			<gf-form-switch class="gf-form" label="Stack" label-class="width-7" checked="ctrl.panel.stack" on-change="ctrl.render()">
+			<gf-form-switch class="gf-form" label="Stack" label-class="width-7" checked="ctrl.panel.stack" on-change="ctrl.refresh()">
 			</gf-form-switch>
 			<gf-form-switch class="gf-form" ng-show="ctrl.panel.stack" label="Percent" label-class="width-7" checked="ctrl.panel.percentage" on-change="ctrl.render()">
 			</gf-form-switch>

+ 7 - 0
public/app/plugins/panel/heatmap/axes_editor.ts

@@ -6,6 +6,7 @@ export class AxesEditorCtrl {
   unitFormats: any;
   logScales: any;
   dataFormats: any;
+  yBucketBoundModes: any;
 
   /** @ngInject */
   constructor($scope, uiSegmentSrv) {
@@ -26,6 +27,12 @@ export class AxesEditorCtrl {
       'Time series': 'timeseries',
       'Time series buckets': 'tsbuckets',
     };
+
+    this.yBucketBoundModes = {
+      Auto: 'auto',
+      Upper: 'upper',
+      Lower: 'lower',
+    };
   }
 
   setUnitFormat(subItem) {

+ 95 - 48
public/app/plugins/panel/heatmap/heatmap_ctrl.ts

@@ -8,8 +8,9 @@ import rendering from './rendering';
 import {
   convertToHeatMap,
   convertToCards,
-  elasticHistogramToHeatmap,
+  histogramToHeatmap,
   calculateBucketSize,
+  sortSeriesByLabel,
 } from './heatmap_data_converter';
 
 let X_BUCKET_NUMBER_DEFAULT = 30;
@@ -32,6 +33,7 @@ let panelDefaults = {
     show: false,
   },
   dataFormat: 'timeseries',
+  yBucketBound: 'auto',
   xAxis: {
     show: true,
   },
@@ -88,6 +90,8 @@ let colorSchemes = [
   { name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'darm' },
 ];
 
+const ds_support_histogram_sort = ['prometheus', 'elasticsearch'];
+
 export class HeatmapCtrl extends MetricsPanelCtrl {
   static templateUrl = 'module.html';
 
@@ -139,61 +143,54 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
       return;
     }
 
-    let xBucketSize, yBucketSize, heatmapStats, bucketsData;
-    let logBase = this.panel.yAxis.logBase;
-
     if (this.panel.dataFormat === 'tsbuckets') {
-      heatmapStats = this.parseHistogramSeries(this.series);
-      bucketsData = elasticHistogramToHeatmap(this.series);
-
-      // Calculate bucket size based on ES heatmap data
-      let xBucketBoundSet = _.map(_.keys(bucketsData), key => Number(key));
-      let yBucketBoundSet = _.map(this.series, series => Number(series.alias));
-      xBucketSize = calculateBucketSize(xBucketBoundSet);
-      yBucketSize = calculateBucketSize(yBucketBoundSet, logBase);
-      if (logBase !== 1) {
-        // Use yBucketSize in meaning of "Split factor" for log scales
-        yBucketSize = 1 / yBucketSize;
-      }
+      this.convertHistogramToHeatmapData();
     } else {
-      let xBucketNumber = this.panel.xBucketNumber || X_BUCKET_NUMBER_DEFAULT;
-      let xBucketSizeByNumber = Math.floor((this.range.to - this.range.from) / xBucketNumber);
-
-      // Parse X bucket size (number or interval)
-      let isIntervalString = kbn.interval_regex.test(this.panel.xBucketSize);
-      if (isIntervalString) {
-        xBucketSize = kbn.interval_to_ms(this.panel.xBucketSize);
-      } else if (
-        isNaN(Number(this.panel.xBucketSize)) ||
-        this.panel.xBucketSize === '' ||
-        this.panel.xBucketSize === null
-      ) {
-        xBucketSize = xBucketSizeByNumber;
-      } else {
-        xBucketSize = Number(this.panel.xBucketSize);
-      }
+      this.convertTimeSeriesToHeatmapData();
+    }
+  }
 
-      // Calculate Y bucket size
-      heatmapStats = this.parseSeries(this.series);
-      let yBucketNumber = this.panel.yBucketNumber || Y_BUCKET_NUMBER_DEFAULT;
-      if (logBase !== 1) {
-        yBucketSize = this.panel.yAxis.splitFactor;
-      } else {
-        if (heatmapStats.max === heatmapStats.min) {
-          if (heatmapStats.max) {
-            yBucketSize = heatmapStats.max / Y_BUCKET_NUMBER_DEFAULT;
-          } else {
-            yBucketSize = 1;
-          }
+  convertTimeSeriesToHeatmapData() {
+    let xBucketSize, yBucketSize, bucketsData, heatmapStats;
+    const logBase = this.panel.yAxis.logBase;
+
+    let xBucketNumber = this.panel.xBucketNumber || X_BUCKET_NUMBER_DEFAULT;
+    let xBucketSizeByNumber = Math.floor((this.range.to - this.range.from) / xBucketNumber);
+
+    // Parse X bucket size (number or interval)
+    let isIntervalString = kbn.interval_regex.test(this.panel.xBucketSize);
+    if (isIntervalString) {
+      xBucketSize = kbn.interval_to_ms(this.panel.xBucketSize);
+    } else if (
+      isNaN(Number(this.panel.xBucketSize)) ||
+      this.panel.xBucketSize === '' ||
+      this.panel.xBucketSize === null
+    ) {
+      xBucketSize = xBucketSizeByNumber;
+    } else {
+      xBucketSize = Number(this.panel.xBucketSize);
+    }
+
+    // Calculate Y bucket size
+    heatmapStats = this.parseSeries(this.series);
+    let yBucketNumber = this.panel.yBucketNumber || Y_BUCKET_NUMBER_DEFAULT;
+    if (logBase !== 1) {
+      yBucketSize = this.panel.yAxis.splitFactor;
+    } else {
+      if (heatmapStats.max === heatmapStats.min) {
+        if (heatmapStats.max) {
+          yBucketSize = heatmapStats.max / Y_BUCKET_NUMBER_DEFAULT;
         } else {
-          yBucketSize = (heatmapStats.max - heatmapStats.min) / yBucketNumber;
+          yBucketSize = 1;
         }
-        yBucketSize = this.panel.yBucketSize || yBucketSize;
+      } else {
+        yBucketSize = (heatmapStats.max - heatmapStats.min) / yBucketNumber;
       }
-
-      bucketsData = convertToHeatMap(this.series, yBucketSize, xBucketSize, logBase);
+      yBucketSize = this.panel.yBucketSize || yBucketSize;
     }
 
+    bucketsData = convertToHeatMap(this.series, yBucketSize, xBucketSize, logBase);
+
     // Set default Y range if no data
     if (!heatmapStats.min && !heatmapStats.max) {
       heatmapStats = { min: -1, max: 1, minLog: 1 };
@@ -212,6 +209,56 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
     };
   }
 
+  convertHistogramToHeatmapData() {
+    const panelDatasource = this.getPanelDataSourceType();
+    let xBucketSize, yBucketSize, bucketsData, tsBuckets;
+
+    // Try to sort series by bucket bound, if datasource doesn't do it.
+    if (!_.includes(ds_support_histogram_sort, panelDatasource)) {
+      this.series.sort(sortSeriesByLabel);
+    }
+
+    // Convert histogram to heatmap. Each histogram bucket represented by the series which name is
+    // a top (or bottom, depends of datasource) bucket bound. Further, these values will be used as X axis labels.
+    bucketsData = histogramToHeatmap(this.series);
+
+    tsBuckets = _.map(this.series, 'label');
+    const yBucketBound = this.panel.yBucketBound;
+    if ((panelDatasource === 'prometheus' && yBucketBound !== 'lower') || yBucketBound === 'upper') {
+      // Prometheus labels are upper inclusive bounds, so add empty bottom bucket label.
+      tsBuckets = [''].concat(tsBuckets);
+    } else {
+      // Elasticsearch uses labels as lower bucket bounds, so add empty top bucket label.
+      // Use this as a default mode as well.
+      tsBuckets.push('');
+    }
+
+    // Calculate bucket size based on heatmap data
+    let xBucketBoundSet = _.map(_.keys(bucketsData), key => Number(key));
+    xBucketSize = calculateBucketSize(xBucketBoundSet);
+    // Always let yBucketSize=1 in 'tsbuckets' mode
+    yBucketSize = 1;
+
+    let { cards, cardStats } = convertToCards(bucketsData);
+
+    this.data = {
+      buckets: bucketsData,
+      xBucketSize: xBucketSize,
+      yBucketSize: yBucketSize,
+      tsBuckets: tsBuckets,
+      cards: cards,
+      cardStats: cardStats,
+    };
+  }
+
+  getPanelDataSourceType() {
+    if (this.datasource.meta && this.datasource.meta.id) {
+      return this.datasource.meta.id;
+    } else {
+      return 'unknown';
+    }
+  }
+
   onDataReceived(dataList) {
     this.series = dataList.map(this.seriesHandler.bind(this));
 

+ 47 - 4
public/app/plugins/panel/heatmap/heatmap_data_converter.ts

@@ -13,11 +13,16 @@ interface YBucket {
   values: number[];
 }
 
-function elasticHistogramToHeatmap(seriesList) {
+/**
+ * Convert histogram represented by the list of series to heatmap object.
+ * @param seriesList List of time series
+ */
+function histogramToHeatmap(seriesList) {
   let heatmap = {};
 
-  for (let series of seriesList) {
-    let bound = Number(series.alias);
+  for (let i = 0; i < seriesList.length; i++) {
+    let series = seriesList[i];
+    let bound = i;
     if (isNaN(bound)) {
       return heatmap;
     }
@@ -51,6 +56,43 @@ function elasticHistogramToHeatmap(seriesList) {
   return heatmap;
 }
 
+/**
+ * Sort series representing histogram by label value.
+ */
+function sortSeriesByLabel(s1, s2) {
+  let label1, label2;
+
+  try {
+    // fail if not integer. might happen with bad queries
+    label1 = parseHistogramLabel(s1.label);
+    label2 = parseHistogramLabel(s2.label);
+  } catch (err) {
+    console.log(err.message || err);
+    return 0;
+  }
+
+  if (label1 > label2) {
+    return 1;
+  }
+
+  if (label1 < label2) {
+    return -1;
+  }
+
+  return 0;
+}
+
+function parseHistogramLabel(label: string): number {
+  if (label === '+Inf' || label === 'inf') {
+    return +Infinity;
+  }
+  const value = Number(label);
+  if (isNaN(value)) {
+    throw new Error(`Error parsing histogram label: ${label} is not a number`);
+  }
+  return value;
+}
+
 /**
  * Convert buckets into linear array of "cards" - objects, represented heatmap elements.
  * @param  {Object} buckets
@@ -433,10 +475,11 @@ function emptyXOR(foo: any, bar: any): boolean {
 
 export {
   convertToHeatMap,
-  elasticHistogramToHeatmap,
+  histogramToHeatmap,
   convertToCards,
   mergeZeroBuckets,
   getValueBucketBound,
   isHeatmapDataEqual,
   calculateBucketSize,
+  sortSeriesByLabel,
 };

+ 35 - 11
public/app/plugins/panel/heatmap/heatmap_tooltip.ts

@@ -97,15 +97,17 @@ export class HeatmapTooltip {
     let time = this.dashboard.formatDate(xData.x, tooltipTimeFormat);
 
     // Decimals override. Code from panel/graph/graph.ts
-    let valueFormatter;
+    let countValueFormatter, bucketBoundFormatter;
     if (_.isNumber(this.panel.tooltipDecimals)) {
-      valueFormatter = this.valueFormatter(this.panel.tooltipDecimals, null);
+      countValueFormatter = this.countValueFormatter(this.panel.tooltipDecimals, null);
+      bucketBoundFormatter = this.panelCtrl.tickValueFormatter(this.panelCtrl.decimals, null);
     } else {
       // auto decimals
       // legend and tooltip gets one more decimal precision
       // than graph legend ticks
       let decimals = (this.panelCtrl.decimals || -1) + 1;
-      valueFormatter = this.valueFormatter(decimals, this.panelCtrl.scaledDecimals + 2);
+      countValueFormatter = this.countValueFormatter(decimals, this.panelCtrl.scaledDecimals + 2);
+      bucketBoundFormatter = this.panelCtrl.tickValueFormatter(decimals, this.panelCtrl.scaledDecimals + 2);
     }
 
     let tooltipHtml = `<div class="graph-tooltip-time">${time}</div>
@@ -113,11 +115,21 @@ export class HeatmapTooltip {
 
     if (yData) {
       if (yData.bounds) {
-        // Display 0 if bucket is a special 'zero' bucket
-        let bottom = yData.y ? yData.bounds.bottom : 0;
-        boundBottom = valueFormatter(bottom);
-        boundTop = valueFormatter(yData.bounds.top);
-        valuesNumber = yData.count;
+        if (data.tsBuckets) {
+          // Use Y-axis labels
+          const tickFormatter = valIndex => {
+            return data.tsBucketsFormatted ? data.tsBucketsFormatted[valIndex] : data.tsBuckets[valIndex];
+          };
+
+          boundBottom = tickFormatter(yBucketIndex);
+          boundTop = yBucketIndex < data.tsBuckets.length - 1 ? tickFormatter(yBucketIndex + 1) : '';
+        } else {
+          // Display 0 if bucket is a special 'zero' bucket
+          let bottom = yData.y ? yData.bounds.bottom : 0;
+          boundBottom = bucketBoundFormatter(bottom);
+          boundTop = bucketBoundFormatter(yData.bounds.top);
+        }
+        valuesNumber = countValueFormatter(yData.count);
         tooltipHtml += `<div>
           bucket: <b>${boundBottom} - ${boundTop}</b> <br>
           count: <b>${valuesNumber}</b> <br>
@@ -163,6 +175,9 @@ export class HeatmapTooltip {
 
   getYBucketIndex(offsetY, data) {
     let y = this.scope.yScale.invert(offsetY - this.scope.chartTop);
+    if (data.tsBuckets) {
+      return Math.floor(y);
+    }
     let yBucketIndex = getValueBucketBound(y, data.yBucketSize, this.panel.yAxis.logBase);
     return yBucketIndex;
   }
@@ -177,7 +192,16 @@ export class HeatmapTooltip {
   addHistogram(data) {
     let xBucket = this.scope.ctrl.data.buckets[data.x];
     let yBucketSize = this.scope.ctrl.data.yBucketSize;
-    let { min, max, ticks } = this.scope.ctrl.data.yAxis;
+    let min, max, ticks;
+    if (this.scope.ctrl.data.tsBuckets) {
+      min = 0;
+      max = this.scope.ctrl.data.tsBuckets.length - 1;
+      ticks = this.scope.ctrl.data.tsBuckets.length;
+    } else {
+      min = this.scope.ctrl.data.yAxis.min;
+      max = this.scope.ctrl.data.yAxis.max;
+      ticks = this.scope.ctrl.data.yAxis.ticks;
+    }
     let histogramData = _.map(xBucket.buckets, bucket => {
       let count = bucket.count !== undefined ? bucket.count : bucket.values.length;
       return [bucket.bounds.bottom, count];
@@ -251,8 +275,8 @@ export class HeatmapTooltip {
     return this.tooltip.style('left', left + 'px').style('top', top + 'px');
   }
 
-  valueFormatter(decimals, scaledDecimals = null) {
-    let format = this.panel.yAxis.format;
+  countValueFormatter(decimals, scaledDecimals = null) {
+    let format = 'short';
     return function(value) {
       return kbn.valueFormats[format](value, decimals, scaledDecimals);
     };

+ 28 - 15
public/app/plugins/panel/heatmap/partials/axes_editor.html

@@ -9,25 +9,36 @@
            dropdown-typeahead-on-select="editor.setUnitFormat($subItem)">
       </div>
     </div>
-    <div class="gf-form">
-      <label class="gf-form-label width-8">Scale</label>
-      <div class="gf-form-select-wrapper width-12">
-        <select class="gf-form-input" ng-model="ctrl.panel.yAxis.logBase" ng-options="v as k for (k, v) in editor.logScales" ng-change="ctrl.refresh()"></select>
+    <div ng-if="ctrl.panel.dataFormat == 'timeseries'">
+      <div class="gf-form">
+        <label class="gf-form-label width-8">Scale</label>
+        <div class="gf-form-select-wrapper width-12">
+          <select class="gf-form-input" ng-model="ctrl.panel.yAxis.logBase" ng-options="v as k for (k, v) in editor.logScales" ng-change="ctrl.refresh()"></select>
+        </div>
+      </div>
+      <div class="gf-form">
+        <label class="gf-form-label width-8">Y-Min</label>
+        <input type="text" class="gf-form-input width-12" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.min" ng-change="ctrl.render()" ng-model-onblur>
+      </div>
+      <div class="gf-form">
+        <label class="gf-form-label width-8">Y-Max</label>
+        <input type="text" class="gf-form-input width-12" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.max" ng-change="ctrl.render()" ng-model-onblur>
       </div>
-    </div>
-    <div class="gf-form">
-      <label class="gf-form-label width-8">Y-Min</label>
-      <input type="text" class="gf-form-input width-12" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.min" ng-change="ctrl.render()" ng-model-onblur>
-    </div>
-    <div class="gf-form">
-      <label class="gf-form-label width-8">Y-Max</label>
-      <input type="text" class="gf-form-input width-12" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.max" ng-change="ctrl.render()" ng-model-onblur>
     </div>
     <div class="gf-form">
       <label class="gf-form-label width-8">Decimals</label>
       <input type="number" class="gf-form-input width-12" placeholder="auto" data-placement="right"
-                                                                             bs-tooltip="'Override automatic decimal precision for axis.'"
-                                                                             ng-model="ctrl.panel.yAxis.decimals" ng-change="ctrl.render()" ng-model-onblur>
+      bs-tooltip="'Override automatic decimal precision for axis.'"
+      ng-model="ctrl.panel.yAxis.decimals" ng-change="ctrl.render()" ng-model-onblur>
+    </div>
+    <div class="gf-form" ng-if="ctrl.panel.dataFormat == 'tsbuckets'">
+      <label class="gf-form-label width-8">Bucket bound</label>
+      <div class="gf-form-select-wrapper max-width-12">
+        <select class="gf-form-input"
+          ng-model="ctrl.panel.yBucketBound" ng-options="v as k for (k, v) in editor.yBucketBoundModes" ng-change="ctrl.render()"
+          data-placement="right" bs-tooltip="'Use series label as an upper or lower bucket bound.'">
+        </select>
+      </div>
     </div>
   </div>
 
@@ -82,7 +93,9 @@
     <div class="gf-form">
       <label class="gf-form-label width-5">Format</label>
       <div class="gf-form-select-wrapper max-width-15">
-        <select class="gf-form-input" ng-model="ctrl.panel.dataFormat" ng-options="v as k for (k, v) in editor.dataFormats" ng-change="ctrl.render()"></select>
+        <select class="gf-form-input" ng-model="ctrl.panel.dataFormat" ng-options="v as k for (k, v) in editor.dataFormats" ng-change="ctrl.render()"
+          data-placement="right" bs-tooltip="'Time series: create heatmap from regular time series. <br>Time series buckets: use histogram data returned from data source. Each series represents bucket which upper/lower bound is a series label.'">
+        </select>
       </div>
     </div>
   </div>

+ 80 - 58
public/app/plugins/panel/heatmap/rendering.ts

@@ -4,7 +4,7 @@ import moment from 'moment';
 import * as d3 from 'd3';
 import kbn from 'app/core/utils/kbn';
 import { appEvents, contextSrv } from 'app/core/core';
-import { tickStep, getScaledDecimals, getFlotTickSize } from 'app/core/utils/ticks';
+import * as ticksUtils from 'app/core/utils/ticks';
 import { HeatmapTooltip } from './heatmap_tooltip';
 import { mergeZeroBuckets } from './heatmap_data_converter';
 import { getColorScale, getOpacityScale } from './color_scale';
@@ -108,7 +108,7 @@ export default function link(scope, elem, attrs, ctrl) {
       .range([0, chartWidth]);
 
     let ticks = chartWidth / DEFAULT_X_TICK_SIZE_PX;
-    let grafanaTimeFormatter = grafanaTimeFormat(ticks, timeRange.from, timeRange.to);
+    let grafanaTimeFormatter = ticksUtils.grafanaTimeFormat(ticks, timeRange.from, timeRange.to);
     let timeFormat;
     let dashboardTimeZone = ctrl.dashboard.getTimezone();
     if (dashboardTimeZone === 'utc') {
@@ -141,7 +141,7 @@ export default function link(scope, elem, attrs, ctrl) {
 
   function addYAxis() {
     let ticks = Math.ceil(chartHeight / DEFAULT_Y_TICK_SIZE_PX);
-    let tick_interval = tickStep(data.heatmapStats.min, data.heatmapStats.max, ticks);
+    let tick_interval = ticksUtils.tickStep(data.heatmapStats.min, data.heatmapStats.max, ticks);
     let { y_min, y_max } = wideYAxisRange(data.heatmapStats.min, data.heatmapStats.max, tick_interval);
 
     // Rewrite min and max if it have been set explicitly
@@ -149,14 +149,14 @@ export default function link(scope, elem, attrs, ctrl) {
     y_max = panel.yAxis.max !== null ? panel.yAxis.max : y_max;
 
     // Adjust ticks after Y range widening
-    tick_interval = tickStep(y_min, y_max, ticks);
+    tick_interval = ticksUtils.tickStep(y_min, y_max, ticks);
     ticks = Math.ceil((y_max - y_min) / tick_interval);
 
-    let decimalsAuto = getPrecision(tick_interval);
+    let decimalsAuto = ticksUtils.getPrecision(tick_interval);
     let decimals = panel.yAxis.decimals === null ? decimalsAuto : panel.yAxis.decimals;
     // Calculate scaledDecimals for log scales using tick size (as in jquery.flot.js)
-    let flot_tick_size = getFlotTickSize(y_min, y_max, ticks, decimalsAuto);
-    let scaledDecimals = getScaledDecimals(decimals, flot_tick_size);
+    let flot_tick_size = ticksUtils.getFlotTickSize(y_min, y_max, ticks, decimalsAuto);
+    let scaledDecimals = ticksUtils.getScaledDecimals(decimals, flot_tick_size);
     ctrl.decimals = decimals;
     ctrl.scaledDecimals = scaledDecimals;
 
@@ -248,12 +248,12 @@ export default function link(scope, elem, attrs, ctrl) {
     let domain = yScale.domain();
     let tick_values = logScaleTickValues(domain, log_base);
 
-    let decimalsAuto = getPrecision(y_min);
+    let decimalsAuto = ticksUtils.getPrecision(y_min);
     let decimals = panel.yAxis.decimals || decimalsAuto;
 
     // Calculate scaledDecimals for log scales using tick size (as in jquery.flot.js)
-    let flot_tick_size = getFlotTickSize(y_min, y_max, tick_values.length, decimalsAuto);
-    let scaledDecimals = getScaledDecimals(decimals, flot_tick_size);
+    let flot_tick_size = ticksUtils.getFlotTickSize(y_min, y_max, tick_values.length, decimalsAuto);
+    let scaledDecimals = ticksUtils.getScaledDecimals(decimals, flot_tick_size);
     ctrl.decimals = decimals;
     ctrl.scaledDecimals = scaledDecimals;
 
@@ -296,6 +296,56 @@ export default function link(scope, elem, attrs, ctrl) {
       .remove();
   }
 
+  function addYAxisFromBuckets() {
+    const tsBuckets = data.tsBuckets;
+
+    scope.yScale = yScale = d3
+      .scaleLinear()
+      .domain([0, tsBuckets.length - 1])
+      .range([chartHeight, 0]);
+
+    const tick_values = _.map(tsBuckets, (b, i) => i);
+    const decimalsAuto = _.max(_.map(tsBuckets, ticksUtils.getStringPrecision));
+    const decimals = panel.yAxis.decimals === null ? decimalsAuto : panel.yAxis.decimals;
+    ctrl.decimals = decimals;
+
+    function tickFormatter(valIndex) {
+      let valueFormatted = tsBuckets[valIndex];
+      if (!_.isNaN(_.toNumber(valueFormatted)) && valueFormatted !== '') {
+        // Try to format numeric tick labels
+        valueFormatted = tickValueFormatter(decimals)(_.toNumber(valueFormatted));
+      }
+      return valueFormatted;
+    }
+
+    const tsBucketsFormatted = _.map(tsBuckets, (v, i) => tickFormatter(i));
+    data.tsBucketsFormatted = tsBucketsFormatted;
+
+    let yAxis = d3
+      .axisLeft(yScale)
+      .tickValues(tick_values)
+      .tickFormat(tickFormatter)
+      .tickSizeInner(0 - width)
+      .tickSizeOuter(0)
+      .tickPadding(Y_AXIS_TICK_PADDING);
+
+    heatmap
+      .append('g')
+      .attr('class', 'axis axis-y')
+      .call(yAxis);
+
+    // Calculate Y axis width first, then move axis into visible area
+    const posY = margin.top;
+    const posX = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
+    heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
+
+    // Remove vertical line in the right of axis labels (called domain in d3)
+    heatmap
+      .select('.axis-y')
+      .select('.domain')
+      .remove();
+  }
+
   // Adjust data range to log base
   function adjustLogRange(min, max, logBase) {
     let y_min, y_max;
@@ -314,11 +364,11 @@ export default function link(scope, elem, attrs, ctrl) {
   }
 
   function adjustLogMax(max, base) {
-    return Math.pow(base, Math.ceil(logp(max, base)));
+    return Math.pow(base, Math.ceil(ticksUtils.logp(max, base)));
   }
 
   function adjustLogMin(min, base) {
-    return Math.pow(base, Math.floor(logp(min, base)));
+    return Math.pow(base, Math.floor(ticksUtils.logp(min, base)));
   }
 
   function logScaleTickValues(domain, base) {
@@ -327,14 +377,14 @@ export default function link(scope, elem, attrs, ctrl) {
     let tickValues = [];
 
     if (domainMin < 1) {
-      let under_one_ticks = Math.floor(logp(domainMin, base));
+      let under_one_ticks = Math.floor(ticksUtils.logp(domainMin, base));
       for (let i = under_one_ticks; i < 0; i++) {
         let tick_value = Math.pow(base, i);
         tickValues.push(tick_value);
       }
     }
 
-    let ticks = Math.ceil(logp(domainMax, base));
+    let ticks = Math.ceil(ticksUtils.logp(domainMax, base));
     for (let i = 0; i <= ticks; i++) {
       let tick_value = Math.pow(base, i);
       tickValues.push(tick_value);
@@ -346,10 +396,17 @@ export default function link(scope, elem, attrs, ctrl) {
   function tickValueFormatter(decimals, scaledDecimals = null) {
     let format = panel.yAxis.format;
     return function(value) {
-      return kbn.valueFormats[format](value, decimals, scaledDecimals);
+      try {
+        return format !== 'none' ? kbn.valueFormats[format](value, decimals, scaledDecimals) : value;
+      } catch (err) {
+        console.error(err.message || err);
+        return value;
+      }
     };
   }
 
+  ctrl.tickValueFormatter = tickValueFormatter;
+
   function fixYAxisTickSize() {
     heatmap
       .select('.axis-y')
@@ -362,10 +419,14 @@ export default function link(scope, elem, attrs, ctrl) {
     chartTop = margin.top;
     chartBottom = chartTop + chartHeight;
 
-    if (panel.yAxis.logBase === 1) {
-      addYAxis();
+    if (panel.dataFormat === 'tsbuckets') {
+      addYAxisFromBuckets();
     } else {
-      addLogYAxis();
+      if (panel.yAxis.logBase === 1) {
+        addYAxis();
+      } else {
+        addLogYAxis();
+      }
     }
 
     yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
@@ -414,7 +475,7 @@ export default function link(scope, elem, attrs, ctrl) {
     addHeatmapCanvas();
     addAxes();
 
-    if (panel.yAxis.logBase !== 1) {
+    if (panel.yAxis.logBase !== 1 && panel.dataFormat !== 'tsbuckets') {
       let log_base = panel.yAxis.logBase;
       let domain = yScale.domain();
       let tick_values = logScaleTickValues(domain, log_base);
@@ -771,42 +832,3 @@ export default function link(scope, elem, attrs, ctrl) {
   $heatmap.on('mousemove', onMouseMove);
   $heatmap.on('mouseleave', onMouseLeave);
 }
-
-function grafanaTimeFormat(ticks, min, max) {
-  if (min && max && ticks) {
-    let range = max - min;
-    let secPerTick = range / ticks / 1000;
-    let oneDay = 86400000;
-    let oneYear = 31536000000;
-
-    if (secPerTick <= 45) {
-      return '%H:%M:%S';
-    }
-    if (secPerTick <= 7200 || range <= oneDay) {
-      return '%H:%M';
-    }
-    if (secPerTick <= 80000) {
-      return '%m/%d %H:%M';
-    }
-    if (secPerTick <= 2419200 || range <= oneYear) {
-      return '%m/%d';
-    }
-    return '%Y-%m';
-  }
-
-  return '%H:%M';
-}
-
-function logp(value, base) {
-  return Math.log(value) / Math.log(base);
-}
-
-function getPrecision(num) {
-  let str = num.toString();
-  let dot_index = str.indexOf('.');
-  if (dot_index === -1) {
-    return 0;
-  } else {
-    return str.length - dot_index - 1;
-  }
-}

+ 34 - 22
public/app/plugins/panel/heatmap/specs/heatmap_data_converter.jest.ts

@@ -4,7 +4,7 @@ import TimeSeries from 'app/core/time_series2';
 import {
   convertToHeatMap,
   convertToCards,
-  elasticHistogramToHeatmap,
+  histogramToHeatmap,
   calculateBucketSize,
   isHeatmapDataEqual,
 } from '../heatmap_data_converter';
@@ -216,7 +216,7 @@ describe('HeatmapDataConverter', () => {
   });
 });
 
-describe('ES Histogram converter', () => {
+describe('Histogram converter', () => {
   let ctx: any = {};
 
   beforeEach(() => {
@@ -244,7 +244,7 @@ describe('ES Histogram converter', () => {
     );
   });
 
-  describe('when converting ES histogram', () => {
+  describe('when converting histogram', () => {
     beforeEach(() => {});
 
     it('should build proper heatmap data', () => {
@@ -252,60 +252,72 @@ describe('ES Histogram converter', () => {
         '1422774000000': {
           x: 1422774000000,
           buckets: {
-            '1': {
-              y: 1,
+            '0': {
+              y: 0,
               count: 1,
+              bounds: { bottom: 0, top: null },
               values: [],
               points: [],
-              bounds: { bottom: 1, top: null },
             },
-            '2': {
-              y: 2,
+            '1': {
+              y: 1,
               count: 5,
+              bounds: { bottom: 1, top: null },
               values: [],
               points: [],
-              bounds: { bottom: 2, top: null },
             },
-            '3': {
-              y: 3,
+            '2': {
+              y: 2,
               count: 0,
+              bounds: { bottom: 2, top: null },
               values: [],
               points: [],
-              bounds: { bottom: 3, top: null },
             },
           },
         },
         '1422774060000': {
           x: 1422774060000,
           buckets: {
-            '1': {
-              y: 1,
+            '0': {
+              y: 0,
               count: 0,
+              bounds: { bottom: 0, top: null },
               values: [],
               points: [],
-              bounds: { bottom: 1, top: null },
             },
-            '2': {
-              y: 2,
+            '1': {
+              y: 1,
               count: 3,
+              bounds: { bottom: 1, top: null },
               values: [],
               points: [],
-              bounds: { bottom: 2, top: null },
             },
-            '3': {
-              y: 3,
+            '2': {
+              y: 2,
               count: 1,
+              bounds: { bottom: 2, top: null },
               values: [],
               points: [],
-              bounds: { bottom: 3, top: null },
             },
           },
         },
       };
 
-      let heatmap = elasticHistogramToHeatmap(ctx.series);
+      const heatmap = histogramToHeatmap(ctx.series);
       expect(heatmap).toEqual(expectedHeatmap);
     });
+
+    it('should use bucket index as a bound', () => {
+      const heatmap = histogramToHeatmap(ctx.series);
+      const bucketLabels = _.map(heatmap['1422774000000'].buckets, (b, label) => label);
+      const bucketYs = _.map(heatmap['1422774000000'].buckets, 'y');
+      const bucketBottoms = _.map(heatmap['1422774000000'].buckets, b => b.bounds.bottom);
+      const expectedBounds = [0, 1, 2];
+
+      expect(bucketLabels).toEqual(_.map(expectedBounds, b => b.toString()));
+      expect(bucketYs).toEqual(expectedBounds);
+      expect(bucketBottoms).toEqual(expectedBounds);
+    });
   });
 });
 

+ 39 - 2
public/app/plugins/panel/heatmap/specs/renderer_specs.ts

@@ -8,7 +8,7 @@ import TimeSeries from 'app/core/time_series2';
 import moment from 'moment';
 import { Emitter } from 'app/core/core';
 import rendering from '../rendering';
-import { convertToHeatMap, convertToCards } from '../heatmap_data_converter';
+import { convertToHeatMap, convertToCards, histogramToHeatmap, calculateBucketSize } from '../heatmap_data_converter';
 
 describe('grafanaHeatmap', function() {
   beforeEach(angularMocks.module('grafana.core'));
@@ -119,7 +119,12 @@ describe('grafanaHeatmap', function() {
             setupFunc(ctrl, ctx);
 
             let logBase = ctrl.panel.yAxis.logBase;
-            let bucketsData = convertToHeatMap(ctx.series, ctx.data.yBucketSize, ctx.data.xBucketSize, logBase);
+            let bucketsData;
+            if (ctrl.panel.dataFormat === 'tsbuckets') {
+              bucketsData = histogramToHeatmap(ctx.series);
+            } else {
+              bucketsData = convertToHeatMap(ctx.series, ctx.data.yBucketSize, ctx.data.xBucketSize, logBase);
+            }
             ctx.data.buckets = bucketsData;
 
             let { cards, cardStats } = convertToCards(bucketsData);
@@ -265,6 +270,38 @@ describe('grafanaHeatmap', function() {
       expect(yTicks).to.eql(['0 ns', '17 min', '33 min', '50 min', '1.11 hour']);
     });
   });
+
+  heatmapScenario('when data format is Time series buckets', function(ctx) {
+    ctx.setup(function(ctrl, ctx) {
+      ctrl.panel.dataFormat = 'tsbuckets';
+
+      const series = [
+        {
+          alias: '1',
+          datapoints: [[1000, 1422774000000], [200000, 1422774060000]],
+        },
+        {
+          alias: '2',
+          datapoints: [[3000, 1422774000000], [400000, 1422774060000]],
+        },
+        {
+          alias: '3',
+          datapoints: [[2000, 1422774000000], [300000, 1422774060000]],
+        },
+      ];
+      ctx.series = series.map(s => new TimeSeries(s));
+
+      ctx.data.tsBuckets = series.map(s => s.alias).concat('');
+      ctx.data.yBucketSize = 1;
+      let xBucketBoundSet = series[0].datapoints.map(dp => dp[1]);
+      ctx.data.xBucketSize = calculateBucketSize(xBucketBoundSet);
+    });
+
+    it('should draw correct Y axis', function() {
+      var yTicks = getTicks(ctx.element, '.axis-y');
+      expect(yTicks).to.eql(['1', '2', '3', '']);
+    });
+  });
 });
 
 function getTicks(element, axisSelector) {

+ 4 - 2
public/app/plugins/panel/singlestat/module.ts

@@ -426,14 +426,16 @@ class SingleStatCtrl extends MetricsPanelCtrl {
       var body = '<div class="singlestat-panel-value-container">';
 
       if (panel.prefix) {
-        body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, panel.prefix);
+        var prefix = applyColoringThresholds(data.value, panel.prefix);
+        body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, prefix);
       }
 
       var value = applyColoringThresholds(data.value, data.valueFormatted);
       body += getSpan('singlestat-panel-value', panel.valueFontSize, value);
 
       if (panel.postfix) {
-        body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, panel.postfix);
+        var postfix = applyColoringThresholds(data.value, panel.postfix);
+        body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, postfix);
       }
 
       body += '</div>';