Commit bf6ff50d by Carl Bergquist Committed by GitHub

Merge pull request #11087 from alexanderzobnin/prometheus-heatmap

Prometheus heatmap support
parents ccd11ac9 989ba976
...@@ -23,3 +23,9 @@ ...@@ -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"
...@@ -25,11 +25,15 @@ scrape_configs: ...@@ -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']
...@@ -23,3 +23,9 @@ ...@@ -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"
...@@ -25,11 +25,15 @@ scrape_configs: ...@@ -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']
# 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"]
...@@ -156,3 +156,61 @@ export function getFlotTickDecimals(data, axis) { ...@@ -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;
}
}
...@@ -4,7 +4,7 @@ import $ from 'jquery'; ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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') { let transformerOptions = {
result.push(self.transformMetricDataToTable(response.data.data.result, responseList.length, index)); format: activeTargets[index].format,
} else { step: queries[index].step,
for (let metricData of response.data.data.result) { legendFormat: activeTargets[index].legendFormat,
if (response.data.data.resultType === 'matrix') { start: start,
result.push(self.transformMetricData(metricData, activeTargets[index], start, end, queries[index].step)); end: end,
} else if (response.data.data.resultType === 'vector') { responseListLength: responseList.length,
result.push(self.transformInstantMetricData(metricData, activeTargets[index])); responseIndex: index,
} };
}
} this.resultTransformer.transform(result, response, transformerOptions);
}); });
return { data: result }; return { data: result };
...@@ -287,9 +288,9 @@ export class PrometheusDatasource { ...@@ -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 { ...@@ -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);
......
...@@ -31,7 +31,11 @@ class PrometheusQueryCtrl extends QueryCtrl { ...@@ -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 { ...@@ -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';
} }
......
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);
}
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);
});
});
});
});
...@@ -224,43 +224,6 @@ describe('PrometheusDatasource', function() { ...@@ -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() { ...@@ -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',
......
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]] },
]);
});
});
});
...@@ -6,6 +6,7 @@ export class AxesEditorCtrl { ...@@ -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 { ...@@ -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) {
......
...@@ -8,8 +8,9 @@ import rendering from './rendering'; ...@@ -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 = { ...@@ -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 = [ ...@@ -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 { ...@@ -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); this.convertHistogramToHeatmapData();
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;
}
} else { } else {
let xBucketNumber = this.panel.xBucketNumber || X_BUCKET_NUMBER_DEFAULT; this.convertTimeSeriesToHeatmapData();
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 convertTimeSeriesToHeatmapData() {
heatmapStats = this.parseSeries(this.series); let xBucketSize, yBucketSize, bucketsData, heatmapStats;
let yBucketNumber = this.panel.yBucketNumber || Y_BUCKET_NUMBER_DEFAULT; const logBase = this.panel.yAxis.logBase;
if (logBase !== 1) {
yBucketSize = this.panel.yAxis.splitFactor; let xBucketNumber = this.panel.xBucketNumber || X_BUCKET_NUMBER_DEFAULT;
} else { let xBucketSizeByNumber = Math.floor((this.range.to - this.range.from) / xBucketNumber);
if (heatmapStats.max === heatmapStats.min) {
if (heatmapStats.max) { // Parse X bucket size (number or interval)
yBucketSize = heatmapStats.max / Y_BUCKET_NUMBER_DEFAULT; let isIntervalString = kbn.interval_regex.test(this.panel.xBucketSize);
} else { if (isIntervalString) {
yBucketSize = 1; 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;
} }
yBucketSize = this.panel.yBucketSize || yBucketSize;
bucketsData = convertToHeatMap(this.series, yBucketSize, xBucketSize, logBase);
} }
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 { ...@@ -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));
......
...@@ -13,11 +13,16 @@ interface YBucket { ...@@ -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) { for (let i = 0; i < seriesList.length; i++) {
let bound = Number(series.alias); let series = seriesList[i];
let bound = i;
if (isNaN(bound)) { if (isNaN(bound)) {
return heatmap; return heatmap;
} }
...@@ -52,6 +57,43 @@ function elasticHistogramToHeatmap(seriesList) { ...@@ -52,6 +57,43 @@ function elasticHistogramToHeatmap(seriesList) {
} }
/** /**
* 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
* @return {Array} Array of "card" objects * @return {Array} Array of "card" objects
...@@ -433,10 +475,11 @@ function emptyXOR(foo: any, bar: any): boolean { ...@@ -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,
}; };
...@@ -97,15 +97,17 @@ export class HeatmapTooltip { ...@@ -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 { ...@@ -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 if (data.tsBuckets) {
let bottom = yData.y ? yData.bounds.bottom : 0; // Use Y-axis labels
boundBottom = valueFormatter(bottom); const tickFormatter = valIndex => {
boundTop = valueFormatter(yData.bounds.top); return data.tsBucketsFormatted ? data.tsBucketsFormatted[valIndex] : data.tsBuckets[valIndex];
valuesNumber = yData.count; };
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 { ...@@ -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 { ...@@ -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 { ...@@ -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) { countValueFormatter(decimals, scaledDecimals = null) {
let format = this.panel.yAxis.format; let format = 'short';
return function(value) { return function(value) {
return kbn.valueFormats[format](value, decimals, scaledDecimals); return kbn.valueFormats[format](value, decimals, scaledDecimals);
}; };
......
...@@ -9,25 +9,36 @@ ...@@ -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"> <div ng-if="ctrl.panel.dataFormat == 'timeseries'">
<label class="gf-form-label width-8">Scale</label> <div class="gf-form">
<div class="gf-form-select-wrapper width-12"> <label class="gf-form-label width-8">Scale</label>
<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 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.'" bs-tooltip="'Override automatic decimal precision for axis.'"
ng-model="ctrl.panel.yAxis.decimals" ng-change="ctrl.render()" ng-model-onblur> 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 @@ ...@@ -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>
......
...@@ -4,7 +4,7 @@ import moment from 'moment'; ...@@ -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) { ...@@ -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) { ...@@ -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) { ...@@ -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 flot_tick_size = ticksUtils.getFlotTickSize(y_min, y_max, ticks, decimalsAuto);
let scaledDecimals = getScaledDecimals(decimals, flot_tick_size); 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) { ...@@ -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 flot_tick_size = ticksUtils.getFlotTickSize(y_min, y_max, tick_values.length, decimalsAuto);
let scaledDecimals = getScaledDecimals(decimals, flot_tick_size); 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) { ...@@ -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) { ...@@ -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) { ...@@ -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) { ...@@ -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) { ...@@ -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) { if (panel.dataFormat === 'tsbuckets') {
addYAxis(); 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) { ...@@ -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) { ...@@ -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;
}
}
...@@ -4,7 +4,7 @@ import TimeSeries from 'app/core/time_series2'; ...@@ -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', () => { ...@@ -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', () => { ...@@ -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', () => { ...@@ -252,60 +252,72 @@ describe('ES Histogram converter', () => {
'1422774000000': { '1422774000000': {
x: 1422774000000, x: 1422774000000,
buckets: { buckets: {
'1': { '0': {
y: 1, y: 0,
count: 1, count: 1,
bounds: { bottom: 0, top: null },
values: [], values: [],
points: [], points: [],
bounds: { bottom: 1, top: null },
}, },
'2': { '1': {
y: 2, y: 1,
count: 5, count: 5,
bounds: { bottom: 1, top: null },
values: [], values: [],
points: [], points: [],
bounds: { bottom: 2, top: null },
}, },
'3': { '2': {
y: 3, 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': { '0': {
y: 1, y: 0,
count: 0, count: 0,
bounds: { bottom: 0, top: null },
values: [], values: [],
points: [], points: [],
bounds: { bottom: 1, top: null },
}, },
'2': { '1': {
y: 2, y: 1,
count: 3, count: 3,
bounds: { bottom: 1, top: null },
values: [], values: [],
points: [], points: [],
bounds: { bottom: 2, top: null },
}, },
'3': { '2': {
y: 3, 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);
});
}); });
}); });
......
...@@ -8,7 +8,7 @@ import TimeSeries from 'app/core/time_series2'; ...@@ -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() { ...@@ -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() { ...@@ -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) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment