Merge remote-tracking branch 'upstream/master' into postgres-query-builder
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>';
|
||||
|
||||
Reference in New Issue
Block a user