Merge pull request #11087 from alexanderzobnin/prometheus-heatmap
Prometheus heatmap support
This commit is contained in:
@@ -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:
|
|||||||
- 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 @@
|
|||||||
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:
|
|||||||
- 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']
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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';
|
|||||||
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') {
|
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 {
|
|||||||
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);
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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]] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,60 +143,53 @@ 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
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
yBucketSize = (heatmapStats.max - heatmapStats.min) / yBucketNumber;
|
|
||||||
}
|
|
||||||
yBucketSize = this.panel.yBucketSize || yBucketSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
bucketsData = convertToHeatMap(this.series, yBucketSize, xBucketSize, logBase);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
convertTimeSeriesToHeatmapData() {
|
||||||
|
let xBucketSize, yBucketSize, bucketsData, heatmapStats;
|
||||||
|
const logBase = this.panel.yAxis.logBase;
|
||||||
|
|
||||||
|
let xBucketNumber = this.panel.xBucketNumber || X_BUCKET_NUMBER_DEFAULT;
|
||||||
|
let xBucketSizeByNumber = Math.floor((this.range.to - this.range.from) / xBucketNumber);
|
||||||
|
|
||||||
|
// Parse X bucket size (number or interval)
|
||||||
|
let isIntervalString = kbn.interval_regex.test(this.panel.xBucketSize);
|
||||||
|
if (isIntervalString) {
|
||||||
|
xBucketSize = kbn.interval_to_ms(this.panel.xBucketSize);
|
||||||
|
} else if (
|
||||||
|
isNaN(Number(this.panel.xBucketSize)) ||
|
||||||
|
this.panel.xBucketSize === '' ||
|
||||||
|
this.panel.xBucketSize === null
|
||||||
|
) {
|
||||||
|
xBucketSize = xBucketSizeByNumber;
|
||||||
|
} else {
|
||||||
|
xBucketSize = Number(this.panel.xBucketSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Y bucket size
|
||||||
|
heatmapStats = this.parseSeries(this.series);
|
||||||
|
let yBucketNumber = this.panel.yBucketNumber || Y_BUCKET_NUMBER_DEFAULT;
|
||||||
|
if (logBase !== 1) {
|
||||||
|
yBucketSize = this.panel.yAxis.splitFactor;
|
||||||
|
} else {
|
||||||
|
if (heatmapStats.max === heatmapStats.min) {
|
||||||
|
if (heatmapStats.max) {
|
||||||
|
yBucketSize = heatmapStats.max / Y_BUCKET_NUMBER_DEFAULT;
|
||||||
|
} else {
|
||||||
|
yBucketSize = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
yBucketSize = (heatmapStats.max - heatmapStats.min) / yBucketNumber;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
@@ -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 {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
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 {
|
|||||||
|
|
||||||
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) {
|
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 @@
|
|||||||
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 @@
|
|||||||
<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';
|
|||||||
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 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) {
|
|||||||
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) {
|
|||||||
.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) {
|
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) {
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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': {
|
'0': {
|
||||||
y: 1,
|
y: 0,
|
||||||
count: 1,
|
count: 1,
|
||||||
|
bounds: { bottom: 0, top: null },
|
||||||
values: [],
|
values: [],
|
||||||
points: [],
|
points: [],
|
||||||
|
},
|
||||||
|
'1': {
|
||||||
|
y: 1,
|
||||||
|
count: 5,
|
||||||
bounds: { bottom: 1, top: null },
|
bounds: { bottom: 1, top: null },
|
||||||
|
values: [],
|
||||||
|
points: [],
|
||||||
},
|
},
|
||||||
'2': {
|
'2': {
|
||||||
y: 2,
|
y: 2,
|
||||||
count: 5,
|
|
||||||
values: [],
|
|
||||||
points: [],
|
|
||||||
bounds: { bottom: 2, top: null },
|
|
||||||
},
|
|
||||||
'3': {
|
|
||||||
y: 3,
|
|
||||||
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: [],
|
||||||
|
},
|
||||||
|
'1': {
|
||||||
|
y: 1,
|
||||||
|
count: 3,
|
||||||
bounds: { bottom: 1, top: null },
|
bounds: { bottom: 1, top: null },
|
||||||
|
values: [],
|
||||||
|
points: [],
|
||||||
},
|
},
|
||||||
'2': {
|
'2': {
|
||||||
y: 2,
|
y: 2,
|
||||||
count: 3,
|
|
||||||
values: [],
|
|
||||||
points: [],
|
|
||||||
bounds: { bottom: 2, top: null },
|
|
||||||
},
|
|
||||||
'3': {
|
|
||||||
y: 3,
|
|
||||||
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';
|
|||||||
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user