Просмотр исходного кода

Merge pull request #11087 from alexanderzobnin/prometheus-heatmap

Prometheus heatmap support
Carl Bergquist 7 лет назад
Родитель
Сommit
bf6ff50d0b

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

@@ -23,3 +23,9 @@
     network_mode: host
     network_mode: host
     ports:
     ports:
       - "9093:9093"
       - "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'
   - job_name: 'node_exporter'
     static_configs:
     static_configs:
       - targets: ['127.0.0.1:9100']
       - targets: ['127.0.0.1:9100']
- 
+
   - job_name: 'fake-data-gen'
   - job_name: 'fake-data-gen'
     static_configs:
     static_configs:
       - targets: ['127.0.0.1:9091']
       - targets: ['127.0.0.1:9091']
-  
+
   - job_name: 'grafana'
   - job_name: 'grafana'
     static_configs:
     static_configs:
       - targets: ['127.0.0.1:3000']
       - 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
     network_mode: host
     ports:
     ports:
       - "9093:9093"
       - "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'
   - job_name: 'node_exporter'
     static_configs:
     static_configs:
       - targets: ['127.0.0.1:9100']
       - targets: ['127.0.0.1:9100']
- 
+
   - job_name: 'fake-data-gen'
   - job_name: 'fake-data-gen'
     static_configs:
     static_configs:
       - targets: ['127.0.0.1:9091']
       - targets: ['127.0.0.1:9091']
-  
+
   - job_name: 'grafana'
   - job_name: 'grafana'
     static_configs:
     static_configs:
       - targets: ['127.0.0.1:3000']
       - 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"]

+ 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);
   const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10);
   return { tickDecimals, scaledDecimals };
   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;
+  }
+}

+ 18 - 138
public/app/plugins/datasource/prometheus/datasource.ts

@@ -4,7 +4,7 @@ import $ from 'jquery';
 import kbn from 'app/core/utils/kbn';
 import kbn from 'app/core/utils/kbn';
 import * as dateMath from 'app/core/utils/datemath';
 import * as dateMath from 'app/core/utils/datemath';
 import PrometheusMetricFindQuery from './metric_find_query';
 import PrometheusMetricFindQuery from './metric_find_query';
-import TableModel from 'app/core/table_model';
+import { ResultTransformer } from './result_transformer';
 
 
 function prometheusSpecialRegexEscape(value) {
 function prometheusSpecialRegexEscape(value) {
   return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\\\$&');
   return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\\\$&');
@@ -22,6 +22,7 @@ export class PrometheusDatasource {
   metricsNameCache: any;
   metricsNameCache: any;
   interval: string;
   interval: string;
   httpMethod: string;
   httpMethod: string;
+  resultTransformer: ResultTransformer;
 
 
   /** @ngInject */
   /** @ngInject */
   constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) {
   constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) {
@@ -34,7 +35,8 @@ export class PrometheusDatasource {
     this.basicAuth = instanceSettings.basicAuth;
     this.basicAuth = instanceSettings.basicAuth;
     this.withCredentials = instanceSettings.withCredentials;
     this.withCredentials = instanceSettings.withCredentials;
     this.interval = instanceSettings.jsonData.timeInterval || '15s';
     this.interval = instanceSettings.jsonData.timeInterval || '15s';
-    this.httpMethod = instanceSettings.jsonData.httpMethod;
+    this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
+    this.resultTransformer = new ResultTransformer(templateSrv);
   }
   }
 
 
   _request(method, url, data?, requestId?) {
   _request(method, url, data?, requestId?) {
@@ -94,7 +96,6 @@ export class PrometheusDatasource {
   }
   }
 
 
   query(options) {
   query(options) {
-    var self = this;
     var start = this.getPrometheusTime(options.range.from, false);
     var start = this.getPrometheusTime(options.range.from, false);
     var end = this.getPrometheusTime(options.range.to, true);
     var end = this.getPrometheusTime(options.range.to, true);
     var range = Math.ceil(end - start);
     var range = Math.ceil(end - start);
@@ -127,24 +128,24 @@ export class PrometheusDatasource {
     });
     });
 
 
     return this.$q.all(allQueryPromise).then(responseList => {
     return this.$q.all(allQueryPromise).then(responseList => {
-      var result = [];
+      let result = [];
 
 
       _.each(responseList, (response, index) => {
       _.each(responseList, (response, index) => {
         if (response.status === 'error') {
         if (response.status === 'error') {
           throw response.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 };
       return { data: result };
@@ -287,9 +288,9 @@ export class PrometheusDatasource {
             var event = {
             var event = {
               annotation: annotation,
               annotation: annotation,
               time: Math.floor(parseFloat(value[0])) * 1000,
               time: Math.floor(parseFloat(value[0])) * 1000,
-              title: self.renderTemplate(titleFormat, series.metric),
+              title: self.resultTransformer.renderTemplate(titleFormat, series.metric),
               tags: tags,
               tags: tags,
-              text: self.renderTemplate(textFormat, series.metric),
+              text: self.resultTransformer.renderTemplate(textFormat, series.metric),
             };
             };
 
 
             eventList.push(event);
             eventList.push(event);
@@ -312,127 +313,6 @@ export class PrometheusDatasource {
     });
     });
   }
   }
 
 
-  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];
-      }
-      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) {
   getPrometheusTime(date, roundUp) {
     if (_.isString(date)) {
     if (_.isString(date)) {
       date = dateMath.parse(date, roundUp);
       date = dateMath.parse(date, roundUp);

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

@@ -31,7 +31,11 @@ class PrometheusQueryCtrl extends QueryCtrl {
       return { factor: f, label: '1/' + f };
       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;
     this.instant = false;
 
 
@@ -45,7 +49,10 @@ class PrometheusQueryCtrl extends QueryCtrl {
   getDefaultFormat() {
   getDefaultFormat() {
     if (this.panelCtrl.panel.type === 'table') {
     if (this.panelCtrl.panel.type === 'table') {
       return 'table';
       return 'table';
+    } else if (this.panelCtrl.panel.type === 'heatmap') {
+      return 'heatmap';
     }
     }
+
     return 'time_series';
     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);
+      });
+    });
+  });
+});

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

@@ -224,43 +224,6 @@ describe('PrometheusDatasource', function() {
       expect(results[0].time).to.be(1443454528 * 1000);
       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() {
   describe('When resultFormat is table and instant = true', function() {
     var results;
     var results;
@@ -294,19 +257,8 @@ describe('PrometheusDatasource', function() {
     it('should return result', () => {
     it('should return result', () => {
       expect(results).not.to.be(null);
       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() {
   describe('The "step" query parameter', function() {
     var response = {
     var response = {
       status: 'success',
       status: 'success',

+ 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]] },
+      ]);
+    });
+  });
+});

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

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

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

@@ -8,8 +8,9 @@ import rendering from './rendering';
 import {
 import {
   convertToHeatMap,
   convertToHeatMap,
   convertToCards,
   convertToCards,
-  elasticHistogramToHeatmap,
+  histogramToHeatmap,
   calculateBucketSize,
   calculateBucketSize,
+  sortSeriesByLabel,
 } from './heatmap_data_converter';
 } from './heatmap_data_converter';
 
 
 let X_BUCKET_NUMBER_DEFAULT = 30;
 let X_BUCKET_NUMBER_DEFAULT = 30;
@@ -32,6 +33,7 @@ let panelDefaults = {
     show: false,
     show: false,
   },
   },
   dataFormat: 'timeseries',
   dataFormat: 'timeseries',
+  yBucketBound: 'auto',
   xAxis: {
   xAxis: {
     show: true,
     show: true,
   },
   },
@@ -88,6 +90,8 @@ let colorSchemes = [
   { name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'darm' },
   { name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'darm' },
 ];
 ];
 
 
+const ds_support_histogram_sort = ['prometheus', 'elasticsearch'];
+
 export class HeatmapCtrl extends MetricsPanelCtrl {
 export class HeatmapCtrl extends MetricsPanelCtrl {
   static templateUrl = 'module.html';
   static templateUrl = 'module.html';
 
 
@@ -139,61 +143,54 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
       return;
       return;
     }
     }
 
 
-    let xBucketSize, yBucketSize, heatmapStats, bucketsData;
-    let logBase = this.panel.yAxis.logBase;
-
     if (this.panel.dataFormat === 'tsbuckets') {
     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 {
     } 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 {
         } 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
     // Set default Y range if no data
     if (!heatmapStats.min && !heatmapStats.max) {
     if (!heatmapStats.min && !heatmapStats.max) {
       heatmapStats = { min: -1, max: 1, minLog: 1 };
       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) {
   onDataReceived(dataList) {
     this.series = dataList.map(this.seriesHandler.bind(this));
     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[];
   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 = {};
   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)) {
     if (isNaN(bound)) {
       return heatmap;
       return heatmap;
     }
     }
@@ -51,6 +56,43 @@ function elasticHistogramToHeatmap(seriesList) {
   return heatmap;
   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.
  * Convert buckets into linear array of "cards" - objects, represented heatmap elements.
  * @param  {Object} buckets
  * @param  {Object} buckets
@@ -433,10 +475,11 @@ function emptyXOR(foo: any, bar: any): boolean {
 
 
 export {
 export {
   convertToHeatMap,
   convertToHeatMap,
-  elasticHistogramToHeatmap,
+  histogramToHeatmap,
   convertToCards,
   convertToCards,
   mergeZeroBuckets,
   mergeZeroBuckets,
   getValueBucketBound,
   getValueBucketBound,
   isHeatmapDataEqual,
   isHeatmapDataEqual,
   calculateBucketSize,
   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);
     let time = this.dashboard.formatDate(xData.x, tooltipTimeFormat);
 
 
     // Decimals override. Code from panel/graph/graph.ts
     // Decimals override. Code from panel/graph/graph.ts
-    let valueFormatter;
+    let countValueFormatter, bucketBoundFormatter;
     if (_.isNumber(this.panel.tooltipDecimals)) {
     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 {
     } else {
       // auto decimals
       // auto decimals
       // legend and tooltip gets one more decimal precision
       // legend and tooltip gets one more decimal precision
       // than graph legend ticks
       // than graph legend ticks
       let decimals = (this.panelCtrl.decimals || -1) + 1;
       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>
     let tooltipHtml = `<div class="graph-tooltip-time">${time}</div>
@@ -113,11 +115,21 @@ export class HeatmapTooltip {
 
 
     if (yData) {
     if (yData) {
       if (yData.bounds) {
       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>
         tooltipHtml += `<div>
           bucket: <b>${boundBottom} - ${boundTop}</b> <br>
           bucket: <b>${boundBottom} - ${boundTop}</b> <br>
           count: <b>${valuesNumber}</b> <br>
           count: <b>${valuesNumber}</b> <br>
@@ -163,6 +175,9 @@ export class HeatmapTooltip {
 
 
   getYBucketIndex(offsetY, data) {
   getYBucketIndex(offsetY, data) {
     let y = this.scope.yScale.invert(offsetY - this.scope.chartTop);
     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);
     let yBucketIndex = getValueBucketBound(y, data.yBucketSize, this.panel.yAxis.logBase);
     return yBucketIndex;
     return yBucketIndex;
   }
   }
@@ -177,7 +192,16 @@ export class HeatmapTooltip {
   addHistogram(data) {
   addHistogram(data) {
     let xBucket = this.scope.ctrl.data.buckets[data.x];
     let xBucket = this.scope.ctrl.data.buckets[data.x];
     let yBucketSize = this.scope.ctrl.data.yBucketSize;
     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 histogramData = _.map(xBucket.buckets, bucket => {
       let count = bucket.count !== undefined ? bucket.count : bucket.values.length;
       let count = bucket.count !== undefined ? bucket.count : bucket.values.length;
       return [bucket.bounds.bottom, count];
       return [bucket.bounds.bottom, count];
@@ -251,8 +275,8 @@ export class HeatmapTooltip {
     return this.tooltip.style('left', left + 'px').style('top', top + 'px');
     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 function(value) {
       return kbn.valueFormats[format](value, decimals, scaledDecimals);
       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)">
            dropdown-typeahead-on-select="editor.setUnitFormat($subItem)">
       </div>
       </div>
     </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>
-    <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">
     <div class="gf-form">
       <label class="gf-form-label width-8">Decimals</label>
       <label class="gf-form-label width-8">Decimals</label>
       <input type="number" class="gf-form-input width-12" placeholder="auto" data-placement="right"
       <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>
   </div>
   </div>
 
 
@@ -82,7 +93,9 @@
     <div class="gf-form">
     <div class="gf-form">
       <label class="gf-form-label width-5">Format</label>
       <label class="gf-form-label width-5">Format</label>
       <div class="gf-form-select-wrapper max-width-15">
       <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>
     </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 * as d3 from 'd3';
 import kbn from 'app/core/utils/kbn';
 import kbn from 'app/core/utils/kbn';
 import { appEvents, contextSrv } from 'app/core/core';
 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 { HeatmapTooltip } from './heatmap_tooltip';
 import { mergeZeroBuckets } from './heatmap_data_converter';
 import { mergeZeroBuckets } from './heatmap_data_converter';
 import { getColorScale, getOpacityScale } from './color_scale';
 import { getColorScale, getOpacityScale } from './color_scale';
@@ -108,7 +108,7 @@ export default function link(scope, elem, attrs, ctrl) {
       .range([0, chartWidth]);
       .range([0, chartWidth]);
 
 
     let ticks = chartWidth / DEFAULT_X_TICK_SIZE_PX;
     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 timeFormat;
     let dashboardTimeZone = ctrl.dashboard.getTimezone();
     let dashboardTimeZone = ctrl.dashboard.getTimezone();
     if (dashboardTimeZone === 'utc') {
     if (dashboardTimeZone === 'utc') {
@@ -141,7 +141,7 @@ export default function link(scope, elem, attrs, ctrl) {
 
 
   function addYAxis() {
   function addYAxis() {
     let ticks = Math.ceil(chartHeight / DEFAULT_Y_TICK_SIZE_PX);
     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);
     let { y_min, y_max } = wideYAxisRange(data.heatmapStats.min, data.heatmapStats.max, tick_interval);
 
 
     // Rewrite min and max if it have been set explicitly
     // 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;
     y_max = panel.yAxis.max !== null ? panel.yAxis.max : y_max;
 
 
     // Adjust ticks after Y range widening
     // 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);
     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;
     let decimals = panel.yAxis.decimals === null ? decimalsAuto : panel.yAxis.decimals;
     // Calculate scaledDecimals for log scales using tick size (as in jquery.flot.js)
     // 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.decimals = decimals;
     ctrl.scaledDecimals = scaledDecimals;
     ctrl.scaledDecimals = scaledDecimals;
 
 
@@ -248,12 +248,12 @@ export default function link(scope, elem, attrs, ctrl) {
     let domain = yScale.domain();
     let domain = yScale.domain();
     let tick_values = logScaleTickValues(domain, log_base);
     let tick_values = logScaleTickValues(domain, log_base);
 
 
-    let decimalsAuto = getPrecision(y_min);
+    let decimalsAuto = ticksUtils.getPrecision(y_min);
     let decimals = panel.yAxis.decimals || decimalsAuto;
     let decimals = panel.yAxis.decimals || decimalsAuto;
 
 
     // Calculate scaledDecimals for log scales using tick size (as in jquery.flot.js)
     // 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.decimals = decimals;
     ctrl.scaledDecimals = scaledDecimals;
     ctrl.scaledDecimals = scaledDecimals;
 
 
@@ -296,6 +296,56 @@ export default function link(scope, elem, attrs, ctrl) {
       .remove();
       .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
   // Adjust data range to log base
   function adjustLogRange(min, max, logBase) {
   function adjustLogRange(min, max, logBase) {
     let y_min, y_max;
     let y_min, y_max;
@@ -314,11 +364,11 @@ export default function link(scope, elem, attrs, ctrl) {
   }
   }
 
 
   function adjustLogMax(max, base) {
   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) {
   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) {
   function logScaleTickValues(domain, base) {
@@ -327,14 +377,14 @@ export default function link(scope, elem, attrs, ctrl) {
     let tickValues = [];
     let tickValues = [];
 
 
     if (domainMin < 1) {
     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++) {
       for (let i = under_one_ticks; i < 0; i++) {
         let tick_value = Math.pow(base, i);
         let tick_value = Math.pow(base, i);
         tickValues.push(tick_value);
         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++) {
     for (let i = 0; i <= ticks; i++) {
       let tick_value = Math.pow(base, i);
       let tick_value = Math.pow(base, i);
       tickValues.push(tick_value);
       tickValues.push(tick_value);
@@ -346,10 +396,17 @@ export default function link(scope, elem, attrs, ctrl) {
   function tickValueFormatter(decimals, scaledDecimals = null) {
   function tickValueFormatter(decimals, scaledDecimals = null) {
     let format = panel.yAxis.format;
     let format = panel.yAxis.format;
     return function(value) {
     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() {
   function fixYAxisTickSize() {
     heatmap
     heatmap
       .select('.axis-y')
       .select('.axis-y')
@@ -362,10 +419,14 @@ export default function link(scope, elem, attrs, ctrl) {
     chartTop = margin.top;
     chartTop = margin.top;
     chartBottom = chartTop + chartHeight;
     chartBottom = chartTop + chartHeight;
 
 
-    if (panel.yAxis.logBase === 1) {
-      addYAxis();
+    if (panel.dataFormat === 'tsbuckets') {
+      addYAxisFromBuckets();
     } else {
     } else {
-      addLogYAxis();
+      if (panel.yAxis.logBase === 1) {
+        addYAxis();
+      } else {
+        addLogYAxis();
+      }
     }
     }
 
 
     yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
     yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
@@ -414,7 +475,7 @@ export default function link(scope, elem, attrs, ctrl) {
     addHeatmapCanvas();
     addHeatmapCanvas();
     addAxes();
     addAxes();
 
 
-    if (panel.yAxis.logBase !== 1) {
+    if (panel.yAxis.logBase !== 1 && panel.dataFormat !== 'tsbuckets') {
       let log_base = panel.yAxis.logBase;
       let log_base = panel.yAxis.logBase;
       let domain = yScale.domain();
       let domain = yScale.domain();
       let tick_values = logScaleTickValues(domain, log_base);
       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('mousemove', onMouseMove);
   $heatmap.on('mouseleave', onMouseLeave);
   $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 {
 import {
   convertToHeatMap,
   convertToHeatMap,
   convertToCards,
   convertToCards,
-  elasticHistogramToHeatmap,
+  histogramToHeatmap,
   calculateBucketSize,
   calculateBucketSize,
   isHeatmapDataEqual,
   isHeatmapDataEqual,
 } from '../heatmap_data_converter';
 } from '../heatmap_data_converter';
@@ -216,7 +216,7 @@ describe('HeatmapDataConverter', () => {
   });
   });
 });
 });
 
 
-describe('ES Histogram converter', () => {
+describe('Histogram converter', () => {
   let ctx: any = {};
   let ctx: any = {};
 
 
   beforeEach(() => {
   beforeEach(() => {
@@ -244,7 +244,7 @@ describe('ES Histogram converter', () => {
     );
     );
   });
   });
 
 
-  describe('when converting ES histogram', () => {
+  describe('when converting histogram', () => {
     beforeEach(() => {});
     beforeEach(() => {});
 
 
     it('should build proper heatmap data', () => {
     it('should build proper heatmap data', () => {
@@ -252,60 +252,72 @@ describe('ES Histogram converter', () => {
         '1422774000000': {
         '1422774000000': {
           x: 1422774000000,
           x: 1422774000000,
           buckets: {
           buckets: {
-            '1': {
-              y: 1,
+            '0': {
+              y: 0,
               count: 1,
               count: 1,
+              bounds: { bottom: 0, top: null },
               values: [],
               values: [],
               points: [],
               points: [],
-              bounds: { bottom: 1, top: null },
             },
             },
-            '2': {
-              y: 2,
+            '1': {
+              y: 1,
               count: 5,
               count: 5,
+              bounds: { bottom: 1, top: null },
               values: [],
               values: [],
               points: [],
               points: [],
-              bounds: { bottom: 2, top: null },
             },
             },
-            '3': {
-              y: 3,
+            '2': {
+              y: 2,
               count: 0,
               count: 0,
+              bounds: { bottom: 2, top: null },
               values: [],
               values: [],
               points: [],
               points: [],
-              bounds: { bottom: 3, top: null },
             },
             },
           },
           },
         },
         },
         '1422774060000': {
         '1422774060000': {
           x: 1422774060000,
           x: 1422774060000,
           buckets: {
           buckets: {
-            '1': {
-              y: 1,
+            '0': {
+              y: 0,
               count: 0,
               count: 0,
+              bounds: { bottom: 0, top: null },
               values: [],
               values: [],
               points: [],
               points: [],
-              bounds: { bottom: 1, top: null },
             },
             },
-            '2': {
-              y: 2,
+            '1': {
+              y: 1,
               count: 3,
               count: 3,
+              bounds: { bottom: 1, top: null },
               values: [],
               values: [],
               points: [],
               points: [],
-              bounds: { bottom: 2, top: null },
             },
             },
-            '3': {
-              y: 3,
+            '2': {
+              y: 2,
               count: 1,
               count: 1,
+              bounds: { bottom: 2, top: null },
               values: [],
               values: [],
               points: [],
               points: [],
-              bounds: { bottom: 3, top: null },
             },
             },
           },
           },
         },
         },
       };
       };
 
 
-      let heatmap = elasticHistogramToHeatmap(ctx.series);
+      const heatmap = histogramToHeatmap(ctx.series);
       expect(heatmap).toEqual(expectedHeatmap);
       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 moment from 'moment';
 import { Emitter } from 'app/core/core';
 import { Emitter } from 'app/core/core';
 import rendering from '../rendering';
 import rendering from '../rendering';
-import { convertToHeatMap, convertToCards } from '../heatmap_data_converter';
+import { convertToHeatMap, convertToCards, histogramToHeatmap, calculateBucketSize } from '../heatmap_data_converter';
 
 
 describe('grafanaHeatmap', function() {
 describe('grafanaHeatmap', function() {
   beforeEach(angularMocks.module('grafana.core'));
   beforeEach(angularMocks.module('grafana.core'));
@@ -119,7 +119,12 @@ describe('grafanaHeatmap', function() {
             setupFunc(ctrl, ctx);
             setupFunc(ctrl, ctx);
 
 
             let logBase = ctrl.panel.yAxis.logBase;
             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;
             ctx.data.buckets = bucketsData;
 
 
             let { cards, cardStats } = convertToCards(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']);
       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) {
 function getTicks(element, axisSelector) {