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

This commit is contained in:
Sven Klemm
2018-03-13 21:31:43 +01:00
44 changed files with 1295 additions and 430 deletions
+10
View File
@@ -1,13 +1,23 @@
# 5.1.0 (unreleased)
* **Prometheus**: The heatmap panel now support Prometheus histograms [#10009](https://github.com/grafana/grafana/issues/10009)
* **Postgres/MySQL**: Ability to insert 0s or nulls for missing intervals [#9487](https://github.com/grafana/grafana/issues/9487), thanks [@svenklemm](https://github.com/svenklemm)
* **Graph**: Thresholds for Right Y axis [#7107](https://github.com/grafana/grafana/issues/7107), thx [@ilgizar](https://github.com/ilgizar)
* **Graph**: Support multiple series stacking in histogram mode [#8151](https://github.com/grafana/grafana/issues/8151), thx [@mtanda](https://github.com/mtanda)
* **Alerting**: Pausing/un alerts now updates new_state_date [#10942](https://github.com/grafana/grafana/pull/10942)
* **Templating**: Add comma templating format [#10632](https://github.com/grafana/grafana/issues/10632), thx [@mtanda](https://github.com/mtanda)
* **Prometheus**: Support POST for query and query_range [#9859](https://github.com/grafana/grafana/pull/9859), thx [@mtanda](https://github.com/mtanda)
### Minor
* **OpsGenie**: Add triggered alerts as description [#11046](https://github.com/grafana/grafana/pull/11046), thx [@llamashoes](https://github.com/llamashoes)
* **Cloudwatch**: Support high resolution metrics [#10925](https://github.com/grafana/grafana/pull/10925), thx [@mtanda](https://github.com/mtanda)
* **Cloudwatch**: Add dimension filtering to CloudWatch `dimension_values()` [#10029](https://github.com/grafana/grafana/issues/10029), thx [@willyhutw](https://github.com/willyhutw)
* **Units**: Second to HH:mm:ss formatter [#11107](https://github.com/grafana/grafana/issues/11107), thx [@gladdiologist](https://github.com/gladdiologist)
* **Singlestat**: Add color to prefix and postfix in singlestat panel [#11143](https://github.com/grafana/grafana/pull/11143), thx [@ApsOps](https://github.com/ApsOps)
# 5.0.2 (unrelease)
* **Teams**: Remove quota restrictions from teams [#11220](https://github.com/grafana/grafana/issues/11220)
# 5.0.1 (2018-03-08)
@@ -23,3 +23,9 @@
network_mode: host
ports:
- "9093:9093"
prometheus-random-data:
build: blocks/prometheus_random_data
network_mode: host
ports:
- "8080:8080"
+6 -2
View File
@@ -25,11 +25,15 @@ scrape_configs:
- job_name: 'node_exporter'
static_configs:
- targets: ['127.0.0.1:9100']
- job_name: 'fake-data-gen'
static_configs:
- targets: ['127.0.0.1:9091']
- job_name: 'grafana'
static_configs:
- targets: ['127.0.0.1:3000']
- job_name: 'prometheus-random-data'
static_configs:
- targets: ['127.0.0.1:8080']
@@ -23,3 +23,9 @@
network_mode: host
ports:
- "9093:9093"
prometheus-random-data:
build: blocks/prometheus_random_data
network_mode: host
ports:
- "8080:8080"
+6 -2
View File
@@ -25,11 +25,15 @@ scrape_configs:
- job_name: 'node_exporter'
static_configs:
- targets: ['127.0.0.1:9100']
- job_name: 'fake-data-gen'
static_configs:
- targets: ['127.0.0.1:9091']
- job_name: 'grafana'
static_configs:
- targets: ['127.0.0.1:3000']
- job_name: 'prometheus-random-data'
static_configs:
- targets: ['127.0.0.1:8080']
@@ -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"]
@@ -87,7 +87,7 @@ Name | Description
*namespaces()* | Returns a list of namespaces CloudWatch support.
*metrics(namespace, [region])* | Returns a list of metrics in the namespace. (specify region or use "default" for custom metrics)
*dimension_keys(namespace)* | Returns a list of dimension keys in the namespace.
*dimension_values(region, namespace, metric, dimension_key)* | Returns a list of dimension values matching the specified `region`, `namespace`, `metric` and `dimension_key`.
*dimension_values(region, namespace, metric, dimension_key, [filters])* | Returns a list of dimension values matching the specified `region`, `namespace`, `metric`, `dimension_key` or you can use dimension `filters` to get more specific result as well.
*ebs_volume_ids(region, instance_id)* | Returns a list of volume ids matching the specified `region`, `instance_id`.
*ec2_instance_attribute(region, attribute_name, filters)* | Returns a list of attributes matching the specified `region`, `attribute_name`, `filters`.
@@ -104,6 +104,7 @@ Query | Service
*dimension_values(us-east-1,AWS/Redshift,CPUUtilization,ClusterIdentifier)* | RedShift
*dimension_values(us-east-1,AWS/RDS,CPUUtilization,DBInstanceIdentifier)* | RDS
*dimension_values(us-east-1,AWS/S3,BucketSizeBytes,BucketName)* | S3
*dimension_values(us-east-1,CWAgent,disk_used_percent,device,{"InstanceId":"$instance_id"})* | CloudWatch Agent
## ec2_instance_attribute examples
@@ -93,7 +93,7 @@ queries via the Dashboard menu / Annotations view.
Prometheus supports two ways to query annotations.
- A regular metric query
- A Prometheus query for pending and firing alerts (for details see [Inspecting alerts during runtime](https://prometheus.io/docs/alerting/rules/#inspecting-alerts-during-runtime))
- A Prometheus query for pending and firing alerts (for details see [Inspecting alerts during runtime](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/#inspecting-alerts-during-runtime))
The step option is useful to limit the number of events returned from your query.
+1
View File
@@ -31,6 +31,7 @@ dashboards, creating users and updating data sources.
* [Annotations API]({{< relref "http_api/annotations.md" >}})
* [Alerting API]({{< relref "http_api/alerting.md" >}})
* [User API]({{< relref "http_api/user.md" >}})
* [Team API]({{< relref "http_api/team.md" >}})
* [Admin API]({{< relref "http_api/admin.md" >}})
* [Preferences API]({{< relref "http_api/preferences.md" >}})
* [Other API]({{< relref "http_api/other.md" >}})
+2 -2
View File
@@ -150,11 +150,11 @@ func (hs *HttpServer) registerRoutes() {
apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
teamsRoute.Get("/:teamId", wrap(GetTeamById))
teamsRoute.Get("/search", wrap(SearchTeams))
teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam))
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), wrap(CreateTeam))
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
teamsRoute.Delete("/:teamId", wrap(DeleteTeamById))
teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers))
teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember))
}, reqOrgAdmin)
+8 -2
View File
@@ -189,8 +189,14 @@ func (proxy *DataSourceProxy) validateRequest() error {
}
if proxy.ds.Type == m.DS_PROMETHEUS {
if proxy.ctx.Req.Request.Method != http.MethodGet || !strings.HasPrefix(proxy.proxyPath, "api/") {
return errors.New("GET is only allowed on proxied Prometheus datasource")
if proxy.ctx.Req.Request.Method == "DELETE" {
return errors.New("Deletes not allowed on proxied Prometheus datasource")
}
if proxy.ctx.Req.Request.Method == "PUT" {
return errors.New("Puts not allowed on proxied Prometheus datasource")
}
if proxy.ctx.Req.Request.Method == "POST" && !(proxy.proxyPath == "api/v1/query" || proxy.proxyPath == "api/v1/query_range") {
return errors.New("Posts not allowed on proxied Prometheus datasource except on /query and /query_range")
}
}
+3 -3
View File
@@ -63,7 +63,7 @@ func checkForUpdates() {
resp, err := httpClient.Get("https://grafana.com/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + setting.BuildVersion)
if err != nil {
log.Trace("Failed to get plugins repo from grafana.net, %v", err.Error())
log.Trace("Failed to get plugins repo from grafana.com, %v", err.Error())
return
}
@@ -101,7 +101,7 @@ func checkForUpdates() {
resp2, err := httpClient.Get("https://raw.githubusercontent.com/grafana/grafana/master/latest.json")
if err != nil {
log.Trace("Failed to get latest.json repo from github: %v", err.Error())
log.Trace("Failed to get latest.json repo from github.com: %v", err.Error())
return
}
@@ -115,7 +115,7 @@ func checkForUpdates() {
var githubLatest GithubLatest
err = json.Unmarshal(body, &githubLatest)
if err != nil {
log.Trace("Failed to unmarshal github latest, reading response from github: %v", err.Error())
log.Trace("Failed to unmarshal github.com latest, reading response from github.com: %v", err.Error())
return
}
+27 -9
View File
@@ -1,7 +1,9 @@
package notifiers
import (
"os"
"strconv"
"time"
"fmt"
@@ -38,7 +40,7 @@ func init() {
}
var (
pagerdutyEventApiUrl string = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
pagerdutyEventApiUrl string = "https://events.pagerduty.com/v2/enqueue"
)
func NewPagerdutyNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
@@ -85,28 +87,41 @@ func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error {
this.log.Info("Notifying Pagerduty", "event_type", eventType)
payloadJSON := simplejson.New()
payloadJSON.Set("summary", evalContext.Rule.Name+" - "+evalContext.Rule.Message)
if hostname, err := os.Hostname(); err == nil {
payloadJSON.Set("source", hostname)
}
payloadJSON.Set("severity", "critical")
payloadJSON.Set("timestamp", time.Now())
payloadJSON.Set("component", "Grafana")
payloadJSON.Set("custom_details", customData)
bodyJSON := simplejson.New()
bodyJSON.Set("service_key", this.Key)
bodyJSON.Set("description", evalContext.Rule.Name+" - "+evalContext.Rule.Message)
bodyJSON.Set("client", "Grafana")
bodyJSON.Set("details", customData)
bodyJSON.Set("event_type", eventType)
bodyJSON.Set("incident_key", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10))
bodyJSON.Set("routing_key", this.Key)
bodyJSON.Set("event_action", eventType)
bodyJSON.Set("dedup_key", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10))
bodyJSON.Set("payload", payloadJSON)
ruleUrl, err := evalContext.GetRuleUrl()
if err != nil {
this.log.Error("Failed get rule link", "error", err)
return err
}
links := make([]interface{}, 1)
linkJSON := simplejson.New()
linkJSON.Set("href", ruleUrl)
bodyJSON.Set("client_url", ruleUrl)
bodyJSON.Set("client", "Grafana")
links[0] = linkJSON
bodyJSON.Set("links", links)
if evalContext.ImagePublicUrl != "" {
contexts := make([]interface{}, 1)
imageJSON := simplejson.New()
imageJSON.Set("type", "image")
imageJSON.Set("src", evalContext.ImagePublicUrl)
contexts[0] = imageJSON
bodyJSON.Set("contexts", contexts)
bodyJSON.Set("images", contexts)
}
body, _ := bodyJSON.MarshalJSON()
@@ -115,6 +130,9 @@ func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error {
Url: pagerdutyEventApiUrl,
Body: string(body),
HttpMethod: "POST",
HttpHeader: map[string]string{
"Content-Type": "application/json",
},
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
+1 -1
View File
@@ -132,7 +132,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
return nil
}
func DeleteAlertDefinition(dashboardId int64, sess *DBSession) error {
func deleteAlertDefinition(dashboardId int64, sess *DBSession) error {
alerts := make([]*m.Alert, 0)
sess.Where("dashboard_id = ?", dashboardId).Find(&alerts)
+1 -1
View File
@@ -330,7 +330,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
}
}
if err := DeleteAlertDefinition(dashboard.Id, sess); err != nil {
if err := deleteAlertDefinition(dashboard.Id, sess); err != nil {
return nil
}
+15
View File
@@ -355,3 +355,18 @@ describe('volume', function() {
expect(str).toBe('1000.0 m3');
});
});
describe('hh:mm:ss', function() {
it('00:04:06', function() {
var str = kbn.valueFormats['dthms'](246, 1);
expect(str).toBe('00:04:06');
});
it('24:00:00', function() {
var str = kbn.valueFormats['dthms'](86400, 1);
expect(str).toBe('24:00:00');
});
it('6824413:53:20', function() {
var str = kbn.valueFormats['dthms'](24567890000, 1);
expect(str).toBe('6824413:53:20');
});
});
+50 -2
View File
@@ -131,6 +131,17 @@ kbn.secondsToHms = function(seconds) {
return 'less than a millisecond'; //'just now' //or other string you like;
};
kbn.secondsToHhmmss = function(seconds) {
var strings = [];
var numhours = Math.floor(seconds/3600);
var numminutes = Math.floor((seconds%3600)/60);
var numseconds = Math.floor((seconds%3600)%60);
numhours > 9 ? strings.push(''+numhours) : strings.push('0'+numhours);
numminutes > 9 ? strings.push(''+numminutes) : strings.push('0'+numminutes);
numseconds > 9 ? strings.push(''+numseconds) : strings.push('0'+numseconds);
return strings.join(':');
};
kbn.to_percent = function(nr, outof) {
return Math.floor(nr / outof * 10000) / 100 + '%';
};
@@ -378,7 +389,6 @@ kbn.valueFormats.short = kbn.formatBuilders.scaledUnits(1000, [
' Sept',
]);
kbn.valueFormats.dB = kbn.formatBuilders.fixedUnit('dB');
kbn.valueFormats.ppm = kbn.formatBuilders.fixedUnit('ppm');
kbn.valueFormats.percent = function(size, decimals) {
if (size === null) {
@@ -557,6 +567,7 @@ kbn.valueFormats.accG = kbn.formatBuilders.fixedUnit('g');
kbn.valueFormats.litre = kbn.formatBuilders.decimalSIPrefix('L');
kbn.valueFormats.mlitre = kbn.formatBuilders.decimalSIPrefix('L', -1);
kbn.valueFormats.m3 = kbn.formatBuilders.fixedUnit('m3');
kbn.valueFormats.Nm3 = kbn.formatBuilders.fixedUnit('Nm3');
kbn.valueFormats.dm3 = kbn.formatBuilders.fixedUnit('dm3');
kbn.valueFormats.gallons = kbn.formatBuilders.fixedUnit('gal');
@@ -582,6 +593,18 @@ kbn.valueFormats.radexpckg = kbn.formatBuilders.decimalSIPrefix('C/kg');
kbn.valueFormats.radr = kbn.formatBuilders.decimalSIPrefix('R');
kbn.valueFormats.radsvh = kbn.formatBuilders.decimalSIPrefix('Sv/h');
// Concentration
kbn.valueFormats.conppm = kbn.formatBuilders.fixedUnit('ppm');
kbn.valueFormats.conppb = kbn.formatBuilders.fixedUnit('ppb');
kbn.valueFormats.conngm3 = kbn.formatBuilders.fixedUnit('ng/m3');
kbn.valueFormats.conngNm3 = kbn.formatBuilders.fixedUnit('ng/Nm3');
kbn.valueFormats.conμgm3 = kbn.formatBuilders.fixedUnit('μg/m3');
kbn.valueFormats.conμgNm3 = kbn.formatBuilders.fixedUnit('μg/Nm3');
kbn.valueFormats.conmgm3 = kbn.formatBuilders.fixedUnit('mg/m3');
kbn.valueFormats.conmgNm3 = kbn.formatBuilders.fixedUnit('mg/Nm3');
kbn.valueFormats.congm3 = kbn.formatBuilders.fixedUnit('g/m3');
kbn.valueFormats.congNm3 = kbn.formatBuilders.fixedUnit('g/Nm3');
// Time
kbn.valueFormats.hertz = kbn.formatBuilders.decimalSIPrefix('Hz');
@@ -783,6 +806,14 @@ kbn.valueFormats.dtdurations = function(size, decimals) {
return kbn.toDuration(size, decimals, 'second');
};
kbn.valueFormats.dthms = function(size, decimals) {
return kbn.secondsToHhmmss(size);
};
kbn.valueFormats.timeticks = function(size, decimals, scaledDecimals) {
return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
};
kbn.valueFormats.dateTimeAsIso = function(epoch) {
var time = moment(epoch);
@@ -817,7 +848,6 @@ kbn.getUnitFormats = function() {
{ text: 'percent (0-100)', value: 'percent' },
{ text: 'percent (0.0-1.0)', value: 'percentunit' },
{ text: 'Humidity (%H)', value: 'humidity' },
{ text: 'ppm', value: 'ppm' },
{ text: 'decibel', value: 'dB' },
{ text: 'hexadecimal (0x)', value: 'hex0x' },
{ text: 'hexadecimal', value: 'hex' },
@@ -854,6 +884,8 @@ kbn.getUnitFormats = function() {
{ text: 'days (d)', value: 'd' },
{ text: 'duration (ms)', value: 'dtdurationms' },
{ text: 'duration (s)', value: 'dtdurations' },
{ text: 'duration (hh:mm:ss)', value: 'dthms' },
{ text: 'Timeticks (s/100)', value: 'timeticks' },
],
},
{
@@ -964,6 +996,7 @@ kbn.getUnitFormats = function() {
{ text: 'millilitre', value: 'mlitre' },
{ text: 'litre', value: 'litre' },
{ text: 'cubic metre', value: 'm3' },
{ text: 'Normal cubic metre', value: 'Nm3' },
{ text: 'cubic decimetre', value: 'dm3' },
{ text: 'gallons', value: 'gallons' },
],
@@ -1061,6 +1094,21 @@ kbn.getUnitFormats = function() {
{ text: 'Sievert/hour (Sv/h)', value: 'radsvh' },
],
},
{
text: 'concentration',
submenu: [
{ text: 'parts-per-million (ppm)', value: 'conppm' },
{ text: 'parts-per-billion (ppb)', value: 'conppb' },
{ text: 'nanogram per cubic metre (ng/m3)', value: 'conngm3' },
{ text: 'nanogram per normal cubic metre (ng/Nm3)', value: 'conngNm3' },
{ text: 'microgram per cubic metre (μg/m3)', value: 'conμgm3' },
{ text: 'microgram per normal cubic metre (μg/Nm3)', value: 'conμgNm3' },
{ text: 'milligram per cubic metre (mg/m3)', value: 'conmgm3' },
{ text: 'milligram per normal cubic metre (mg/Nm3)', value: 'conmgNm3' },
{ text: 'gram per cubic metre (g/m3)', value: 'congm3' },
{ text: 'gram per normal cubic metre (g/Nm3)', value: 'congNm3' },
],
},
];
};
+58
View File
@@ -156,3 +156,61 @@ export function getFlotTickDecimals(data, axis) {
const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10);
return { tickDecimals, scaledDecimals };
}
/**
* Format timestamp similar to Grafana graph panel.
* @param ticks Number of ticks
* @param min Time from (in milliseconds)
* @param max Time to (in milliseconds)
*/
export function grafanaTimeFormat(ticks, min, max) {
if (min && max && ticks) {
let range = max - min;
let secPerTick = range / ticks / 1000;
let oneDay = 86400000;
let oneYear = 31536000000;
if (secPerTick <= 45) {
return '%H:%M:%S';
}
if (secPerTick <= 7200 || range <= oneDay) {
return '%H:%M';
}
if (secPerTick <= 80000) {
return '%m/%d %H:%M';
}
if (secPerTick <= 2419200 || range <= oneYear) {
return '%m/%d';
}
return '%Y-%m';
}
return '%H:%M';
}
/**
* Logarithm of value for arbitrary base.
*/
export function logp(value, base) {
return Math.log(value) / Math.log(base);
}
/**
* Get decimal precision of number (3.14 => 2)
*/
export function getPrecision(num: number): number {
let str = num.toString();
return getStringPrecision(str);
}
/**
* Get decimal precision of number stored as a string ("3.14" => 2)
*/
export function getStringPrecision(num: string): number {
let dot_index = num.indexOf('.');
if (dot_index === -1) {
return 0;
} else {
return num.length - dot_index - 1;
}
}
@@ -212,6 +212,7 @@ export default class CloudWatchDatasource {
var region;
var namespace;
var metricName;
var filterJson;
var regionQuery = query.match(/^regions\(\)/);
if (regionQuery) {
@@ -237,14 +238,20 @@ export default class CloudWatchDatasource {
return this.getDimensionKeys(namespace, region);
}
var dimensionValuesQuery = query.match(/^dimension_values\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)\)/);
var dimensionValuesQuery = query.match(
/^dimension_values\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)(,\s?(.+))?\)/
);
if (dimensionValuesQuery) {
region = dimensionValuesQuery[1];
namespace = dimensionValuesQuery[2];
metricName = dimensionValuesQuery[3];
var dimensionKey = dimensionValuesQuery[4];
filterJson = {};
if (dimensionValuesQuery[6]) {
filterJson = JSON.parse(this.templateSrv.replace(dimensionValuesQuery[6]));
}
return this.getDimensionValues(region, namespace, metricName, dimensionKey, {});
return this.getDimensionValues(region, namespace, metricName, dimensionKey, filterJson);
}
var ebsVolumeIdsQuery = query.match(/^ebs_volume_ids\(([^,]+?),\s?([^,]+?)\)/);
@@ -258,7 +265,7 @@ export default class CloudWatchDatasource {
if (ec2InstanceAttributeQuery) {
region = ec2InstanceAttributeQuery[1];
var targetAttributeName = ec2InstanceAttributeQuery[2];
var filterJson = JSON.parse(this.templateSrv.replace(ec2InstanceAttributeQuery[3]));
filterJson = JSON.parse(this.templateSrv.replace(ec2InstanceAttributeQuery[3]));
return this.getEc2InstanceAttribute(region, targetAttributeName, filterJson);
}
@@ -0,0 +1,9 @@
export class PrometheusConfigCtrl {
static templateUrl = 'public/app/plugins/datasource/prometheus/partials/config.html';
current: any;
/** @ngInject */
constructor($scope) {
this.current.jsonData.httpMethod = this.current.jsonData.httpMethod || 'GET';
}
}
@@ -1,9 +1,10 @@
import _ from 'lodash';
import $ from 'jquery';
import kbn from 'app/core/utils/kbn';
import * as dateMath from 'app/core/utils/datemath';
import PrometheusMetricFindQuery from './metric_find_query';
import TableModel from 'app/core/table_model';
import { ResultTransformer } from './result_transformer';
function prometheusSpecialRegexEscape(value) {
return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\\\$&');
@@ -20,6 +21,8 @@ export class PrometheusDatasource {
withCredentials: any;
metricsNameCache: any;
interval: string;
httpMethod: string;
resultTransformer: ResultTransformer;
/** @ngInject */
constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) {
@@ -32,14 +35,34 @@ export class PrometheusDatasource {
this.basicAuth = instanceSettings.basicAuth;
this.withCredentials = instanceSettings.withCredentials;
this.interval = instanceSettings.jsonData.timeInterval || '15s';
this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
this.resultTransformer = new ResultTransformer(templateSrv);
}
_request(method, url, requestId?) {
_request(method, url, data?, requestId?) {
var options: any = {
url: this.url + url,
method: method,
requestId: requestId,
};
if (method === 'GET') {
if (!_.isEmpty(data)) {
options.url =
options.url +
'?' +
_.map(data, (v, k) => {
return encodeURIComponent(k) + '=' + encodeURIComponent(v);
}).join('&');
}
} else {
options.headers = {
'Content-Type': 'application/x-www-form-urlencoded',
};
options.transformRequest = data => {
return $.param(data);
};
options.data = data;
}
if (this.basicAuth || this.withCredentials) {
options.withCredentials = true;
@@ -73,7 +96,6 @@ export class PrometheusDatasource {
}
query(options) {
var self = this;
var start = this.getPrometheusTime(options.range.from, false);
var end = this.getPrometheusTime(options.range.to, true);
var range = Math.ceil(end - start);
@@ -106,24 +128,24 @@ export class PrometheusDatasource {
});
return this.$q.all(allQueryPromise).then(responseList => {
var result = [];
let result = [];
_.each(responseList, (response, index) => {
if (response.status === 'error') {
throw response.error;
}
if (activeTargets[index].format === 'table') {
result.push(self.transformMetricDataToTable(response.data.data.result, responseList.length, index));
} else {
for (let metricData of response.data.data.result) {
if (response.data.data.resultType === 'matrix') {
result.push(self.transformMetricData(metricData, activeTargets[index], start, end, queries[index].step));
} else if (response.data.data.resultType === 'vector') {
result.push(self.transformInstantMetricData(metricData, activeTargets[index]));
}
}
}
let transformerOptions = {
format: activeTargets[index].format,
step: queries[index].step,
legendFormat: activeTargets[index].legendFormat,
start: start,
end: end,
responseListLength: responseList.length,
responseIndex: index,
};
this.resultTransformer.transform(result, response, transformerOptions);
});
return { data: result };
@@ -173,21 +195,23 @@ export class PrometheusDatasource {
throw { message: 'Invalid time range' };
}
var url =
'/api/v1/query_range?query=' +
encodeURIComponent(query.expr) +
'&start=' +
start +
'&end=' +
end +
'&step=' +
query.step;
return this._request('GET', url, query.requestId);
var url = '/api/v1/query_range';
var data = {
query: query.expr,
start: start,
end: end,
step: query.step,
};
return this._request(this.httpMethod, url, data, query.requestId);
}
performInstantQuery(query, time) {
var url = '/api/v1/query?query=' + encodeURIComponent(query.expr) + '&time=' + time;
return this._request('GET', url, query.requestId);
var url = '/api/v1/query';
var data = {
query: query.expr,
time: time,
};
return this._request(this.httpMethod, url, data, query.requestId);
}
performSuggestQuery(query, cache = false) {
@@ -264,9 +288,9 @@ export class PrometheusDatasource {
var event = {
annotation: annotation,
time: Math.floor(parseFloat(value[0])) * 1000,
title: self.renderTemplate(titleFormat, series.metric),
title: self.resultTransformer.renderTemplate(titleFormat, series.metric),
tags: tags,
text: self.renderTemplate(textFormat, series.metric),
text: self.resultTransformer.renderTemplate(textFormat, series.metric),
};
eventList.push(event);
@@ -279,130 +303,14 @@ export class PrometheusDatasource {
}
testDatasource() {
return this.metricFindQuery('metrics(.*)').then(function() {
return { status: 'success', message: 'Data source is working' };
});
}
transformMetricData(md, options, start, end, step) {
var dps = [],
metricLabel = null;
metricLabel = this.createMetricLabel(md.metric, options);
var stepMs = step * 1000;
var baseTimestamp = start * 1000;
for (let value of md.values) {
var dp_value = parseFloat(value[1]);
if (_.isNaN(dp_value)) {
dp_value = null;
}
var timestamp = parseFloat(value[0]) * 1000;
for (let t = baseTimestamp; t < timestamp; t += stepMs) {
dps.push([null, t]);
}
baseTimestamp = timestamp + stepMs;
dps.push([dp_value, timestamp]);
}
var endTimestamp = end * 1000;
for (let t = baseTimestamp; t <= endTimestamp; t += stepMs) {
dps.push([null, t]);
}
return { target: metricLabel, datapoints: dps };
}
transformMetricDataToTable(md, resultCount: number, resultIndex: number) {
var table = new TableModel();
var i, j;
var metricLabels = {};
if (md.length === 0) {
return table;
}
// Collect all labels across all metrics
_.each(md, function(series) {
for (var label in series.metric) {
if (!metricLabels.hasOwnProperty(label)) {
metricLabels[label] = 1;
}
let now = new Date().getTime();
return this.performInstantQuery({ expr: '1+1' }, now / 1000).then(response => {
if (response.data.status === 'success') {
return { status: 'success', message: 'Data source is working' };
} else {
return { status: 'error', message: response.error };
}
});
// 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) {
@@ -1,9 +1,6 @@
import { PrometheusDatasource } from './datasource';
import { PrometheusQueryCtrl } from './query_ctrl';
class PrometheusConfigCtrl {
static templateUrl = 'partials/config.html';
}
import { PrometheusConfigCtrl } from './config_ctrl';
class PrometheusAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html';
@@ -4,13 +4,23 @@
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label">Scrape interval</span>
<input type="text" class="gf-form-input width-6" ng-model="ctrl.current.jsonData.timeInterval" spellcheck='false' placeholder="15s"></input>
<span class="gf-form-label width-8">Scrape interval</span>
<input type="text" class="gf-form-input width-8" ng-model="ctrl.current.jsonData.timeInterval" spellcheck='false' placeholder="15s"></input>
<info-popover mode="right-absolute">
Set this to your global scrape interval defined in your Prometheus config file. This will be used as a lower limit for
Set this to your global scrape interval defined in your Prometheus config file. This will be used as a lower limit for
the Prometheus step query parameter.
</info-popover>
</div>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">HTTP Method</label>
<div class="gf-form-select-wrapper width-8 gf-form-select-wrapper--has-help-icon">
<select class="gf-form-input" ng-model="ctrl.current.jsonData.httpMethod" ng-options="method for method in ['GET', 'POST']"></select>
<info-popover mode="right-absolute">
Specify the HTTP Method to query Prometheus. (POST is only available in Prometheus >= v2.1.0)
</info-popover>
</div>
</div>
</div>
@@ -31,7 +31,11 @@ class PrometheusQueryCtrl extends QueryCtrl {
return { factor: f, label: '1/' + f };
});
this.formats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }];
this.formats = [
{ text: 'Time series', value: 'time_series' },
{ text: 'Table', value: 'table' },
{ text: 'Heatmap', value: 'heatmap' },
];
this.instant = false;
@@ -45,7 +49,10 @@ class PrometheusQueryCtrl extends QueryCtrl {
getDefaultFormat() {
if (this.panelCtrl.panel.type === 'table') {
return 'table';
} else if (this.panelCtrl.panel.type === 'heatmap') {
return 'heatmap';
}
return 'time_series';
}
@@ -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);
});
});
});
});
@@ -1,5 +1,6 @@
import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
import moment from 'moment';
import $ from 'jquery';
import helpers from 'test/specs/helpers';
import { PrometheusDatasource } from '../datasource';
@@ -10,7 +11,7 @@ describe('PrometheusDatasource', function() {
directUrl: 'direct',
user: 'test',
password: 'mupp',
jsonData: {},
jsonData: { httpMethod: 'GET' },
};
beforeEach(angularMocks.module('grafana.core'));
@@ -223,43 +224,6 @@ describe('PrometheusDatasource', function() {
expect(results[0].time).to.be(1443454528 * 1000);
});
});
describe('When resultFormat is table', function() {
var response = {
status: 'success',
data: {
resultType: 'matrix',
result: [
{
metric: { __name__: 'test', job: 'testjob' },
values: [[1443454528, '3846']],
},
{
metric: {
__name__: 'test',
instance: 'localhost:8080',
job: 'otherjob',
},
values: [[1443454529, '3847']],
},
],
},
};
it('should return table model', function() {
var table = ctx.ds.transformMetricDataToTable(response.data.result);
expect(table.type).to.be('table');
expect(table.rows).to.eql([
[1443454528000, 'test', '', 'testjob', 3846],
[1443454529000, 'test', 'localhost:8080', 'otherjob', 3847],
]);
expect(table.columns).to.eql([
{ text: 'Time', type: 'time' },
{ text: '__name__' },
{ text: 'instance' },
{ text: 'job' },
{ text: 'Value' },
]);
});
});
describe('When resultFormat is table and instant = true', function() {
var results;
@@ -293,19 +257,8 @@ describe('PrometheusDatasource', function() {
it('should return result', () => {
expect(results).not.to.be(null);
});
it('should return table model', function() {
var table = ctx.ds.transformMetricDataToTable(response.data.result);
expect(table.type).to.be('table');
expect(table.rows).to.eql([[1443454528000, 'test', 'testjob', 3846]]);
expect(table.columns).to.eql([
{ text: 'Time', type: 'time' },
{ text: '__name__' },
{ text: 'job' },
{ text: 'Value' },
]);
});
});
describe('The "step" query parameter', function() {
var response = {
status: 'success',
@@ -652,3 +605,70 @@ describe('PrometheusDatasource', function() {
});
});
});
describe('PrometheusDatasource for POST', function() {
var ctx = new helpers.ServiceTestContext();
var instanceSettings = {
url: 'proxied',
directUrl: 'direct',
user: 'test',
password: 'mupp',
jsonData: { httpMethod: 'POST' },
};
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(ctx.providePhase(['timeSrv']));
beforeEach(
angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) {
ctx.$q = $q;
ctx.$httpBackend = $httpBackend;
ctx.$rootScope = $rootScope;
ctx.ds = $injector.instantiate(PrometheusDatasource, { instanceSettings: instanceSettings });
$httpBackend.when('GET', /\.html$/).respond('');
})
);
describe('When querying prometheus with one target using query editor target spec', function() {
var results;
var urlExpected = 'proxied/api/v1/query_range';
var dataExpected = $.param({
query: 'test{job="testjob"}',
start: 1443438675,
end: 1443460275,
step: 60,
});
var query = {
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{ expr: 'test{job="testjob"}', format: 'time_series' }],
interval: '60s',
};
var response = {
status: 'success',
data: {
resultType: 'matrix',
result: [
{
metric: { __name__: 'test', job: 'testjob' },
values: [[1443454528, '3846']],
},
],
},
};
beforeEach(function() {
ctx.$httpBackend.expectPOST(urlExpected, dataExpected).respond(response);
ctx.ds.query(query).then(function(data) {
results = data;
});
ctx.$httpBackend.flush();
});
it('should generate the correct query', function() {
ctx.$httpBackend.verifyNoOutstandingExpectation();
});
it('should return series list', function() {
expect(results.data.length).to.be(1);
expect(results.data[0].target).to.be('test{job="testjob"}');
});
});
});
@@ -12,7 +12,7 @@ describe('PrometheusMetricFindQuery', function() {
directUrl: 'direct',
user: 'test',
password: 'mupp',
jsonData: {},
jsonData: { httpMethod: 'GET' },
};
beforeEach(angularMocks.module('grafana.core'));
@@ -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]] },
]);
});
});
});
@@ -29,12 +29,17 @@ export class DataProcessor {
});
}
case 'histogram': {
let histogramDataList = [
{
target: 'count',
datapoints: _.concat([], _.flatten(_.map(options.dataList, 'datapoints'))),
},
];
let histogramDataList;
if (this.panel.stack) {
histogramDataList = options.dataList;
} else {
histogramDataList = [
{
target: 'count',
datapoints: _.concat([], _.flatten(_.map(options.dataList, 'datapoints'))),
},
];
}
return histogramDataList.map((item, index) => {
return this.timeSeriesHandler(item, index, options);
});
+10 -6
View File
@@ -17,7 +17,7 @@ import { appEvents, coreModule, updateLegendValues } from 'app/core/core';
import GraphTooltip from './graph_tooltip';
import { ThresholdManager } from './threshold_manager';
import { EventManager } from 'app/features/annotations/all';
import { convertValuesToHistogram, getSeriesValues } from './histogram';
import { convertToHistogramData } from './histogram';
import config from 'app/core/config';
/** @ngInject **/
@@ -236,16 +236,14 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) {
}
case 'histogram': {
let bucketSize: number;
let values = getSeriesValues(data);
if (data.length && values.length) {
if (data.length) {
let histMin = _.min(_.map(data, s => s.stats.min));
let histMax = _.max(_.map(data, s => s.stats.max));
let ticks = panel.xaxis.buckets || panelWidth / 50;
bucketSize = tickStep(histMin, histMax, ticks);
let histogram = convertValuesToHistogram(values, bucketSize);
data[0].data = histogram;
options.series.bars.barWidth = bucketSize * 0.8;
data = convertToHistogramData(data, bucketSize, ctrl.hiddenSeries, histMin, histMax);
} else {
bucketSize = 0;
}
@@ -413,7 +411,13 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) {
let defaultTicks = panelWidth / 50;
if (data.length && bucketSize) {
ticks = _.map(data[0].data, point => point[0]);
let tick_values = [];
for (let d of data) {
for (let point of d.data) {
tick_values[point[0]] = true;
}
}
ticks = Object.keys(tick_values).map(v => Number(v));
min = _.min(ticks);
max = _.max(ticks);
+37 -6
View File
@@ -29,16 +29,22 @@ export function getSeriesValues(dataList: TimeSeries[]): number[] {
* @param values
* @param bucketSize
*/
export function convertValuesToHistogram(values: number[], bucketSize: number): any[] {
export function convertValuesToHistogram(values: number[], bucketSize: number, min: number, max: number): any[] {
let histogram = {};
let minBound = getBucketBound(min, bucketSize);
let maxBound = getBucketBound(max, bucketSize);
let bound = minBound;
let n = 0;
while (bound <= maxBound) {
histogram[bound] = 0;
bound = minBound + bucketSize * n;
n++;
}
for (let i = 0; i < values.length; i++) {
let bound = getBucketBound(values[i], bucketSize);
if (histogram[bound]) {
histogram[bound] = histogram[bound] + 1;
} else {
histogram[bound] = 1;
}
histogram[bound] = histogram[bound] + 1;
}
let histogam_series = _.map(histogram, (count, bound) => {
@@ -49,6 +55,31 @@ export function convertValuesToHistogram(values: number[], bucketSize: number):
return _.sortBy(histogam_series, point => point[0]);
}
/**
* Convert series into array of histogram data.
* @param data Array of series
* @param bucketSize
*/
export function convertToHistogramData(
data: any,
bucketSize: number,
hiddenSeries: any,
min: number,
max: number
): any[] {
return data.map(series => {
let values = getSeriesValues([series]);
series.histogram = true;
if (!hiddenSeries[series.alias]) {
let histogram = convertValuesToHistogram(values, bucketSize, min, max);
series.data = histogram;
} else {
series.data = [];
}
return series;
});
}
function getBucketBound(value: number, bucketSize: number): number {
return Math.floor(value / bucketSize) * bucketSize;
}
@@ -407,4 +407,48 @@ describe('grafanaGraph', function() {
},
10
);
graphScenario('when graph is histogram, and enable stack', function(ctx) {
ctx.setup(function(ctrl, data) {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.stack = true;
ctrl.hiddenSeries = {};
data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
data[1] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series2',
});
});
it('should calculate correct histogram', function() {
expect(ctx.plotData[0].data[0][0]).to.be(100);
expect(ctx.plotData[0].data[0][1]).to.be(2);
expect(ctx.plotData[1].data[0][0]).to.be(100);
expect(ctx.plotData[1].data[0][1]).to.be(2);
});
});
graphScenario('when graph is histogram, and some series are hidden', function(ctx) {
ctx.setup(function(ctrl, data) {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.stack = false;
ctrl.hiddenSeries = { series2: true };
data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
data[1] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series2',
});
});
it('should calculate correct histogram', function() {
expect(ctx.plotData[0].data[0][0]).to.be(100);
expect(ctx.plotData[0].data[0][1]).to.be(2);
});
});
});
@@ -13,15 +13,15 @@ describe('Graph Histogam Converter', function() {
bucketSize = 10;
let expected = [[0, 2], [10, 3], [20, 2]];
let histogram = convertValuesToHistogram(values, bucketSize);
let histogram = convertValuesToHistogram(values, bucketSize, 1, 29);
expect(histogram).toMatchObject(expected);
});
it('Should not add empty buckets', () => {
bucketSize = 5;
let expected = [[0, 2], [10, 2], [15, 1], [20, 1], [25, 1]];
let expected = [[0, 2], [5, 0], [10, 2], [15, 1], [20, 1], [25, 1]];
let histogram = convertValuesToHistogram(values, bucketSize);
let histogram = convertValuesToHistogram(values, bucketSize, 1, 29);
expect(histogram).toMatchObject(expected);
});
});
@@ -71,7 +71,7 @@
<div class="section gf-form-group">
<h5 class="section-heading">Stacking & Null value</h5>
<gf-form-switch class="gf-form" label="Stack" label-class="width-7" checked="ctrl.panel.stack" on-change="ctrl.render()">
<gf-form-switch class="gf-form" label="Stack" label-class="width-7" checked="ctrl.panel.stack" on-change="ctrl.refresh()">
</gf-form-switch>
<gf-form-switch class="gf-form" ng-show="ctrl.panel.stack" label="Percent" label-class="width-7" checked="ctrl.panel.percentage" on-change="ctrl.render()">
</gf-form-switch>
@@ -6,6 +6,7 @@ export class AxesEditorCtrl {
unitFormats: any;
logScales: any;
dataFormats: any;
yBucketBoundModes: any;
/** @ngInject */
constructor($scope, uiSegmentSrv) {
@@ -26,6 +27,12 @@ export class AxesEditorCtrl {
'Time series': 'timeseries',
'Time series buckets': 'tsbuckets',
};
this.yBucketBoundModes = {
Auto: 'auto',
Upper: 'upper',
Lower: 'lower',
};
}
setUnitFormat(subItem) {
@@ -8,8 +8,9 @@ import rendering from './rendering';
import {
convertToHeatMap,
convertToCards,
elasticHistogramToHeatmap,
histogramToHeatmap,
calculateBucketSize,
sortSeriesByLabel,
} from './heatmap_data_converter';
let X_BUCKET_NUMBER_DEFAULT = 30;
@@ -32,6 +33,7 @@ let panelDefaults = {
show: false,
},
dataFormat: 'timeseries',
yBucketBound: 'auto',
xAxis: {
show: true,
},
@@ -88,6 +90,8 @@ let colorSchemes = [
{ name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'darm' },
];
const ds_support_histogram_sort = ['prometheus', 'elasticsearch'];
export class HeatmapCtrl extends MetricsPanelCtrl {
static templateUrl = 'module.html';
@@ -139,60 +143,53 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
return;
}
let xBucketSize, yBucketSize, heatmapStats, bucketsData;
let logBase = this.panel.yAxis.logBase;
if (this.panel.dataFormat === 'tsbuckets') {
heatmapStats = this.parseHistogramSeries(this.series);
bucketsData = elasticHistogramToHeatmap(this.series);
// Calculate bucket size based on ES heatmap data
let xBucketBoundSet = _.map(_.keys(bucketsData), key => Number(key));
let yBucketBoundSet = _.map(this.series, series => Number(series.alias));
xBucketSize = calculateBucketSize(xBucketBoundSet);
yBucketSize = calculateBucketSize(yBucketBoundSet, logBase);
if (logBase !== 1) {
// Use yBucketSize in meaning of "Split factor" for log scales
yBucketSize = 1 / yBucketSize;
}
this.convertHistogramToHeatmapData();
} else {
let xBucketNumber = this.panel.xBucketNumber || X_BUCKET_NUMBER_DEFAULT;
let xBucketSizeByNumber = Math.floor((this.range.to - this.range.from) / xBucketNumber);
// Parse X bucket size (number or interval)
let isIntervalString = kbn.interval_regex.test(this.panel.xBucketSize);
if (isIntervalString) {
xBucketSize = kbn.interval_to_ms(this.panel.xBucketSize);
} else if (
isNaN(Number(this.panel.xBucketSize)) ||
this.panel.xBucketSize === '' ||
this.panel.xBucketSize === null
) {
xBucketSize = xBucketSizeByNumber;
} else {
xBucketSize = Number(this.panel.xBucketSize);
}
// 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);
this.convertTimeSeriesToHeatmapData();
}
}
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
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) {
this.series = dataList.map(this.seriesHandler.bind(this));
@@ -13,11 +13,16 @@ interface YBucket {
values: number[];
}
function elasticHistogramToHeatmap(seriesList) {
/**
* Convert histogram represented by the list of series to heatmap object.
* @param seriesList List of time series
*/
function histogramToHeatmap(seriesList) {
let heatmap = {};
for (let series of seriesList) {
let bound = Number(series.alias);
for (let i = 0; i < seriesList.length; i++) {
let series = seriesList[i];
let bound = i;
if (isNaN(bound)) {
return heatmap;
}
@@ -51,6 +56,43 @@ function elasticHistogramToHeatmap(seriesList) {
return heatmap;
}
/**
* Sort series representing histogram by label value.
*/
function sortSeriesByLabel(s1, s2) {
let label1, label2;
try {
// fail if not integer. might happen with bad queries
label1 = parseHistogramLabel(s1.label);
label2 = parseHistogramLabel(s2.label);
} catch (err) {
console.log(err.message || err);
return 0;
}
if (label1 > label2) {
return 1;
}
if (label1 < label2) {
return -1;
}
return 0;
}
function parseHistogramLabel(label: string): number {
if (label === '+Inf' || label === 'inf') {
return +Infinity;
}
const value = Number(label);
if (isNaN(value)) {
throw new Error(`Error parsing histogram label: ${label} is not a number`);
}
return value;
}
/**
* Convert buckets into linear array of "cards" - objects, represented heatmap elements.
* @param {Object} buckets
@@ -433,10 +475,11 @@ function emptyXOR(foo: any, bar: any): boolean {
export {
convertToHeatMap,
elasticHistogramToHeatmap,
histogramToHeatmap,
convertToCards,
mergeZeroBuckets,
getValueBucketBound,
isHeatmapDataEqual,
calculateBucketSize,
sortSeriesByLabel,
};
@@ -97,15 +97,17 @@ export class HeatmapTooltip {
let time = this.dashboard.formatDate(xData.x, tooltipTimeFormat);
// Decimals override. Code from panel/graph/graph.ts
let valueFormatter;
let countValueFormatter, bucketBoundFormatter;
if (_.isNumber(this.panel.tooltipDecimals)) {
valueFormatter = this.valueFormatter(this.panel.tooltipDecimals, null);
countValueFormatter = this.countValueFormatter(this.panel.tooltipDecimals, null);
bucketBoundFormatter = this.panelCtrl.tickValueFormatter(this.panelCtrl.decimals, null);
} else {
// auto decimals
// legend and tooltip gets one more decimal precision
// than graph legend ticks
let decimals = (this.panelCtrl.decimals || -1) + 1;
valueFormatter = this.valueFormatter(decimals, this.panelCtrl.scaledDecimals + 2);
countValueFormatter = this.countValueFormatter(decimals, this.panelCtrl.scaledDecimals + 2);
bucketBoundFormatter = this.panelCtrl.tickValueFormatter(decimals, this.panelCtrl.scaledDecimals + 2);
}
let tooltipHtml = `<div class="graph-tooltip-time">${time}</div>
@@ -113,11 +115,21 @@ export class HeatmapTooltip {
if (yData) {
if (yData.bounds) {
// Display 0 if bucket is a special 'zero' bucket
let bottom = yData.y ? yData.bounds.bottom : 0;
boundBottom = valueFormatter(bottom);
boundTop = valueFormatter(yData.bounds.top);
valuesNumber = yData.count;
if (data.tsBuckets) {
// Use Y-axis labels
const tickFormatter = valIndex => {
return data.tsBucketsFormatted ? data.tsBucketsFormatted[valIndex] : data.tsBuckets[valIndex];
};
boundBottom = tickFormatter(yBucketIndex);
boundTop = yBucketIndex < data.tsBuckets.length - 1 ? tickFormatter(yBucketIndex + 1) : '';
} else {
// Display 0 if bucket is a special 'zero' bucket
let bottom = yData.y ? yData.bounds.bottom : 0;
boundBottom = bucketBoundFormatter(bottom);
boundTop = bucketBoundFormatter(yData.bounds.top);
}
valuesNumber = countValueFormatter(yData.count);
tooltipHtml += `<div>
bucket: <b>${boundBottom} - ${boundTop}</b> <br>
count: <b>${valuesNumber}</b> <br>
@@ -163,6 +175,9 @@ export class HeatmapTooltip {
getYBucketIndex(offsetY, data) {
let y = this.scope.yScale.invert(offsetY - this.scope.chartTop);
if (data.tsBuckets) {
return Math.floor(y);
}
let yBucketIndex = getValueBucketBound(y, data.yBucketSize, this.panel.yAxis.logBase);
return yBucketIndex;
}
@@ -177,7 +192,16 @@ export class HeatmapTooltip {
addHistogram(data) {
let xBucket = this.scope.ctrl.data.buckets[data.x];
let yBucketSize = this.scope.ctrl.data.yBucketSize;
let { min, max, ticks } = this.scope.ctrl.data.yAxis;
let min, max, ticks;
if (this.scope.ctrl.data.tsBuckets) {
min = 0;
max = this.scope.ctrl.data.tsBuckets.length - 1;
ticks = this.scope.ctrl.data.tsBuckets.length;
} else {
min = this.scope.ctrl.data.yAxis.min;
max = this.scope.ctrl.data.yAxis.max;
ticks = this.scope.ctrl.data.yAxis.ticks;
}
let histogramData = _.map(xBucket.buckets, bucket => {
let count = bucket.count !== undefined ? bucket.count : bucket.values.length;
return [bucket.bounds.bottom, count];
@@ -251,8 +275,8 @@ export class HeatmapTooltip {
return this.tooltip.style('left', left + 'px').style('top', top + 'px');
}
valueFormatter(decimals, scaledDecimals = null) {
let format = this.panel.yAxis.format;
countValueFormatter(decimals, scaledDecimals = null) {
let format = 'short';
return function(value) {
return kbn.valueFormats[format](value, decimals, scaledDecimals);
};
@@ -9,25 +9,36 @@
dropdown-typeahead-on-select="editor.setUnitFormat($subItem)">
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Scale</label>
<div class="gf-form-select-wrapper width-12">
<select class="gf-form-input" ng-model="ctrl.panel.yAxis.logBase" ng-options="v as k for (k, v) in editor.logScales" ng-change="ctrl.refresh()"></select>
<div ng-if="ctrl.panel.dataFormat == 'timeseries'">
<div class="gf-form">
<label class="gf-form-label width-8">Scale</label>
<div class="gf-form-select-wrapper width-12">
<select class="gf-form-input" ng-model="ctrl.panel.yAxis.logBase" ng-options="v as k for (k, v) in editor.logScales" ng-change="ctrl.refresh()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Y-Min</label>
<input type="text" class="gf-form-input width-12" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.min" ng-change="ctrl.render()" ng-model-onblur>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Y-Max</label>
<input type="text" class="gf-form-input width-12" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.max" ng-change="ctrl.render()" ng-model-onblur>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Y-Min</label>
<input type="text" class="gf-form-input width-12" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.min" ng-change="ctrl.render()" ng-model-onblur>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Y-Max</label>
<input type="text" class="gf-form-input width-12" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.max" ng-change="ctrl.render()" ng-model-onblur>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Decimals</label>
<input type="number" class="gf-form-input width-12" placeholder="auto" data-placement="right"
bs-tooltip="'Override automatic decimal precision for axis.'"
ng-model="ctrl.panel.yAxis.decimals" ng-change="ctrl.render()" ng-model-onblur>
bs-tooltip="'Override automatic decimal precision for axis.'"
ng-model="ctrl.panel.yAxis.decimals" ng-change="ctrl.render()" ng-model-onblur>
</div>
<div class="gf-form" ng-if="ctrl.panel.dataFormat == 'tsbuckets'">
<label class="gf-form-label width-8">Bucket bound</label>
<div class="gf-form-select-wrapper max-width-12">
<select class="gf-form-input"
ng-model="ctrl.panel.yBucketBound" ng-options="v as k for (k, v) in editor.yBucketBoundModes" ng-change="ctrl.render()"
data-placement="right" bs-tooltip="'Use series label as an upper or lower bucket bound.'">
</select>
</div>
</div>
</div>
@@ -82,7 +93,9 @@
<div class="gf-form">
<label class="gf-form-label width-5">Format</label>
<div class="gf-form-select-wrapper max-width-15">
<select class="gf-form-input" ng-model="ctrl.panel.dataFormat" ng-options="v as k for (k, v) in editor.dataFormats" ng-change="ctrl.render()"></select>
<select class="gf-form-input" ng-model="ctrl.panel.dataFormat" ng-options="v as k for (k, v) in editor.dataFormats" ng-change="ctrl.render()"
data-placement="right" bs-tooltip="'Time series: create heatmap from regular time series. <br>Time series buckets: use histogram data returned from data source. Each series represents bucket which upper/lower bound is a series label.'">
</select>
</div>
</div>
</div>
+80 -58
View File
@@ -4,7 +4,7 @@ import moment from 'moment';
import * as d3 from 'd3';
import kbn from 'app/core/utils/kbn';
import { appEvents, contextSrv } from 'app/core/core';
import { tickStep, getScaledDecimals, getFlotTickSize } from 'app/core/utils/ticks';
import * as ticksUtils from 'app/core/utils/ticks';
import { HeatmapTooltip } from './heatmap_tooltip';
import { mergeZeroBuckets } from './heatmap_data_converter';
import { getColorScale, getOpacityScale } from './color_scale';
@@ -108,7 +108,7 @@ export default function link(scope, elem, attrs, ctrl) {
.range([0, chartWidth]);
let ticks = chartWidth / DEFAULT_X_TICK_SIZE_PX;
let grafanaTimeFormatter = grafanaTimeFormat(ticks, timeRange.from, timeRange.to);
let grafanaTimeFormatter = ticksUtils.grafanaTimeFormat(ticks, timeRange.from, timeRange.to);
let timeFormat;
let dashboardTimeZone = ctrl.dashboard.getTimezone();
if (dashboardTimeZone === 'utc') {
@@ -141,7 +141,7 @@ export default function link(scope, elem, attrs, ctrl) {
function addYAxis() {
let ticks = Math.ceil(chartHeight / DEFAULT_Y_TICK_SIZE_PX);
let tick_interval = tickStep(data.heatmapStats.min, data.heatmapStats.max, ticks);
let tick_interval = ticksUtils.tickStep(data.heatmapStats.min, data.heatmapStats.max, ticks);
let { y_min, y_max } = wideYAxisRange(data.heatmapStats.min, data.heatmapStats.max, tick_interval);
// Rewrite min and max if it have been set explicitly
@@ -149,14 +149,14 @@ export default function link(scope, elem, attrs, ctrl) {
y_max = panel.yAxis.max !== null ? panel.yAxis.max : y_max;
// Adjust ticks after Y range widening
tick_interval = tickStep(y_min, y_max, ticks);
tick_interval = ticksUtils.tickStep(y_min, y_max, ticks);
ticks = Math.ceil((y_max - y_min) / tick_interval);
let decimalsAuto = getPrecision(tick_interval);
let decimalsAuto = ticksUtils.getPrecision(tick_interval);
let decimals = panel.yAxis.decimals === null ? decimalsAuto : panel.yAxis.decimals;
// Calculate scaledDecimals for log scales using tick size (as in jquery.flot.js)
let flot_tick_size = getFlotTickSize(y_min, y_max, ticks, decimalsAuto);
let scaledDecimals = getScaledDecimals(decimals, flot_tick_size);
let flot_tick_size = ticksUtils.getFlotTickSize(y_min, y_max, ticks, decimalsAuto);
let scaledDecimals = ticksUtils.getScaledDecimals(decimals, flot_tick_size);
ctrl.decimals = decimals;
ctrl.scaledDecimals = scaledDecimals;
@@ -248,12 +248,12 @@ export default function link(scope, elem, attrs, ctrl) {
let domain = yScale.domain();
let tick_values = logScaleTickValues(domain, log_base);
let decimalsAuto = getPrecision(y_min);
let decimalsAuto = ticksUtils.getPrecision(y_min);
let decimals = panel.yAxis.decimals || decimalsAuto;
// Calculate scaledDecimals for log scales using tick size (as in jquery.flot.js)
let flot_tick_size = getFlotTickSize(y_min, y_max, tick_values.length, decimalsAuto);
let scaledDecimals = getScaledDecimals(decimals, flot_tick_size);
let flot_tick_size = ticksUtils.getFlotTickSize(y_min, y_max, tick_values.length, decimalsAuto);
let scaledDecimals = ticksUtils.getScaledDecimals(decimals, flot_tick_size);
ctrl.decimals = decimals;
ctrl.scaledDecimals = scaledDecimals;
@@ -296,6 +296,56 @@ export default function link(scope, elem, attrs, ctrl) {
.remove();
}
function addYAxisFromBuckets() {
const tsBuckets = data.tsBuckets;
scope.yScale = yScale = d3
.scaleLinear()
.domain([0, tsBuckets.length - 1])
.range([chartHeight, 0]);
const tick_values = _.map(tsBuckets, (b, i) => i);
const decimalsAuto = _.max(_.map(tsBuckets, ticksUtils.getStringPrecision));
const decimals = panel.yAxis.decimals === null ? decimalsAuto : panel.yAxis.decimals;
ctrl.decimals = decimals;
function tickFormatter(valIndex) {
let valueFormatted = tsBuckets[valIndex];
if (!_.isNaN(_.toNumber(valueFormatted)) && valueFormatted !== '') {
// Try to format numeric tick labels
valueFormatted = tickValueFormatter(decimals)(_.toNumber(valueFormatted));
}
return valueFormatted;
}
const tsBucketsFormatted = _.map(tsBuckets, (v, i) => tickFormatter(i));
data.tsBucketsFormatted = tsBucketsFormatted;
let yAxis = d3
.axisLeft(yScale)
.tickValues(tick_values)
.tickFormat(tickFormatter)
.tickSizeInner(0 - width)
.tickSizeOuter(0)
.tickPadding(Y_AXIS_TICK_PADDING);
heatmap
.append('g')
.attr('class', 'axis axis-y')
.call(yAxis);
// Calculate Y axis width first, then move axis into visible area
const posY = margin.top;
const posX = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
// Remove vertical line in the right of axis labels (called domain in d3)
heatmap
.select('.axis-y')
.select('.domain')
.remove();
}
// Adjust data range to log base
function adjustLogRange(min, max, logBase) {
let y_min, y_max;
@@ -314,11 +364,11 @@ export default function link(scope, elem, attrs, ctrl) {
}
function adjustLogMax(max, base) {
return Math.pow(base, Math.ceil(logp(max, base)));
return Math.pow(base, Math.ceil(ticksUtils.logp(max, base)));
}
function adjustLogMin(min, base) {
return Math.pow(base, Math.floor(logp(min, base)));
return Math.pow(base, Math.floor(ticksUtils.logp(min, base)));
}
function logScaleTickValues(domain, base) {
@@ -327,14 +377,14 @@ export default function link(scope, elem, attrs, ctrl) {
let tickValues = [];
if (domainMin < 1) {
let under_one_ticks = Math.floor(logp(domainMin, base));
let under_one_ticks = Math.floor(ticksUtils.logp(domainMin, base));
for (let i = under_one_ticks; i < 0; i++) {
let tick_value = Math.pow(base, i);
tickValues.push(tick_value);
}
}
let ticks = Math.ceil(logp(domainMax, base));
let ticks = Math.ceil(ticksUtils.logp(domainMax, base));
for (let i = 0; i <= ticks; i++) {
let tick_value = Math.pow(base, i);
tickValues.push(tick_value);
@@ -346,10 +396,17 @@ export default function link(scope, elem, attrs, ctrl) {
function tickValueFormatter(decimals, scaledDecimals = null) {
let format = panel.yAxis.format;
return function(value) {
return kbn.valueFormats[format](value, decimals, scaledDecimals);
try {
return format !== 'none' ? kbn.valueFormats[format](value, decimals, scaledDecimals) : value;
} catch (err) {
console.error(err.message || err);
return value;
}
};
}
ctrl.tickValueFormatter = tickValueFormatter;
function fixYAxisTickSize() {
heatmap
.select('.axis-y')
@@ -362,10 +419,14 @@ export default function link(scope, elem, attrs, ctrl) {
chartTop = margin.top;
chartBottom = chartTop + chartHeight;
if (panel.yAxis.logBase === 1) {
addYAxis();
if (panel.dataFormat === 'tsbuckets') {
addYAxisFromBuckets();
} else {
addLogYAxis();
if (panel.yAxis.logBase === 1) {
addYAxis();
} else {
addLogYAxis();
}
}
yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
@@ -414,7 +475,7 @@ export default function link(scope, elem, attrs, ctrl) {
addHeatmapCanvas();
addAxes();
if (panel.yAxis.logBase !== 1) {
if (panel.yAxis.logBase !== 1 && panel.dataFormat !== 'tsbuckets') {
let log_base = panel.yAxis.logBase;
let domain = yScale.domain();
let tick_values = logScaleTickValues(domain, log_base);
@@ -771,42 +832,3 @@ export default function link(scope, elem, attrs, ctrl) {
$heatmap.on('mousemove', onMouseMove);
$heatmap.on('mouseleave', onMouseLeave);
}
function grafanaTimeFormat(ticks, min, max) {
if (min && max && ticks) {
let range = max - min;
let secPerTick = range / ticks / 1000;
let oneDay = 86400000;
let oneYear = 31536000000;
if (secPerTick <= 45) {
return '%H:%M:%S';
}
if (secPerTick <= 7200 || range <= oneDay) {
return '%H:%M';
}
if (secPerTick <= 80000) {
return '%m/%d %H:%M';
}
if (secPerTick <= 2419200 || range <= oneYear) {
return '%m/%d';
}
return '%Y-%m';
}
return '%H:%M';
}
function logp(value, base) {
return Math.log(value) / Math.log(base);
}
function getPrecision(num) {
let str = num.toString();
let dot_index = str.indexOf('.');
if (dot_index === -1) {
return 0;
} else {
return str.length - dot_index - 1;
}
}
@@ -4,7 +4,7 @@ import TimeSeries from 'app/core/time_series2';
import {
convertToHeatMap,
convertToCards,
elasticHistogramToHeatmap,
histogramToHeatmap,
calculateBucketSize,
isHeatmapDataEqual,
} from '../heatmap_data_converter';
@@ -216,7 +216,7 @@ describe('HeatmapDataConverter', () => {
});
});
describe('ES Histogram converter', () => {
describe('Histogram converter', () => {
let ctx: any = {};
beforeEach(() => {
@@ -244,7 +244,7 @@ describe('ES Histogram converter', () => {
);
});
describe('when converting ES histogram', () => {
describe('when converting histogram', () => {
beforeEach(() => {});
it('should build proper heatmap data', () => {
@@ -252,60 +252,72 @@ describe('ES Histogram converter', () => {
'1422774000000': {
x: 1422774000000,
buckets: {
'1': {
y: 1,
'0': {
y: 0,
count: 1,
bounds: { bottom: 0, top: null },
values: [],
points: [],
},
'1': {
y: 1,
count: 5,
bounds: { bottom: 1, top: null },
values: [],
points: [],
},
'2': {
y: 2,
count: 5,
values: [],
points: [],
bounds: { bottom: 2, top: null },
},
'3': {
y: 3,
count: 0,
bounds: { bottom: 2, top: null },
values: [],
points: [],
bounds: { bottom: 3, top: null },
},
},
},
'1422774060000': {
x: 1422774060000,
buckets: {
'1': {
y: 1,
'0': {
y: 0,
count: 0,
bounds: { bottom: 0, top: null },
values: [],
points: [],
},
'1': {
y: 1,
count: 3,
bounds: { bottom: 1, top: null },
values: [],
points: [],
},
'2': {
y: 2,
count: 3,
values: [],
points: [],
bounds: { bottom: 2, top: null },
},
'3': {
y: 3,
count: 1,
bounds: { bottom: 2, top: null },
values: [],
points: [],
bounds: { bottom: 3, top: null },
},
},
},
};
let heatmap = elasticHistogramToHeatmap(ctx.series);
const heatmap = histogramToHeatmap(ctx.series);
expect(heatmap).toEqual(expectedHeatmap);
});
it('should use bucket index as a bound', () => {
const heatmap = histogramToHeatmap(ctx.series);
const bucketLabels = _.map(heatmap['1422774000000'].buckets, (b, label) => label);
const bucketYs = _.map(heatmap['1422774000000'].buckets, 'y');
const bucketBottoms = _.map(heatmap['1422774000000'].buckets, b => b.bounds.bottom);
const expectedBounds = [0, 1, 2];
expect(bucketLabels).toEqual(_.map(expectedBounds, b => b.toString()));
expect(bucketYs).toEqual(expectedBounds);
expect(bucketBottoms).toEqual(expectedBounds);
});
});
});
@@ -8,7 +8,7 @@ import TimeSeries from 'app/core/time_series2';
import moment from 'moment';
import { Emitter } from 'app/core/core';
import rendering from '../rendering';
import { convertToHeatMap, convertToCards } from '../heatmap_data_converter';
import { convertToHeatMap, convertToCards, histogramToHeatmap, calculateBucketSize } from '../heatmap_data_converter';
describe('grafanaHeatmap', function() {
beforeEach(angularMocks.module('grafana.core'));
@@ -119,7 +119,12 @@ describe('grafanaHeatmap', function() {
setupFunc(ctrl, ctx);
let logBase = ctrl.panel.yAxis.logBase;
let bucketsData = convertToHeatMap(ctx.series, ctx.data.yBucketSize, ctx.data.xBucketSize, logBase);
let bucketsData;
if (ctrl.panel.dataFormat === 'tsbuckets') {
bucketsData = histogramToHeatmap(ctx.series);
} else {
bucketsData = convertToHeatMap(ctx.series, ctx.data.yBucketSize, ctx.data.xBucketSize, logBase);
}
ctx.data.buckets = bucketsData;
let { cards, cardStats } = convertToCards(bucketsData);
@@ -265,6 +270,38 @@ describe('grafanaHeatmap', function() {
expect(yTicks).to.eql(['0 ns', '17 min', '33 min', '50 min', '1.11 hour']);
});
});
heatmapScenario('when data format is Time series buckets', function(ctx) {
ctx.setup(function(ctrl, ctx) {
ctrl.panel.dataFormat = 'tsbuckets';
const series = [
{
alias: '1',
datapoints: [[1000, 1422774000000], [200000, 1422774060000]],
},
{
alias: '2',
datapoints: [[3000, 1422774000000], [400000, 1422774060000]],
},
{
alias: '3',
datapoints: [[2000, 1422774000000], [300000, 1422774060000]],
},
];
ctx.series = series.map(s => new TimeSeries(s));
ctx.data.tsBuckets = series.map(s => s.alias).concat('');
ctx.data.yBucketSize = 1;
let xBucketBoundSet = series[0].datapoints.map(dp => dp[1]);
ctx.data.xBucketSize = calculateBucketSize(xBucketBoundSet);
});
it('should draw correct Y axis', function() {
var yTicks = getTicks(ctx.element, '.axis-y');
expect(yTicks).to.eql(['1', '2', '3', '']);
});
});
});
function getTicks(element, axisSelector) {
@@ -426,14 +426,16 @@ class SingleStatCtrl extends MetricsPanelCtrl {
var body = '<div class="singlestat-panel-value-container">';
if (panel.prefix) {
body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, panel.prefix);
var prefix = applyColoringThresholds(data.value, panel.prefix);
body += getSpan('singlestat-panel-prefix', panel.prefixFontSize, prefix);
}
var value = applyColoringThresholds(data.value, data.valueFormatted);
body += getSpan('singlestat-panel-value', panel.valueFontSize, value);
if (panel.postfix) {
body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, panel.postfix);
var postfix = applyColoringThresholds(data.value, panel.postfix);
body += getSpan('singlestat-panel-postfix', panel.postfixFontSize, postfix);
}
body += '</div>';