diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1e5007e47..e4de9ccb9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docker/blocks/prometheus/docker-compose.yaml b/docker/blocks/prometheus/docker-compose.yaml index ccb1238a179..a65bb9a9e4f 100644 --- a/docker/blocks/prometheus/docker-compose.yaml +++ b/docker/blocks/prometheus/docker-compose.yaml @@ -23,3 +23,9 @@ network_mode: host ports: - "9093:9093" + + prometheus-random-data: + build: blocks/prometheus_random_data + network_mode: host + ports: + - "8080:8080" diff --git a/docker/blocks/prometheus/prometheus.yml b/docker/blocks/prometheus/prometheus.yml index ae40dfdf067..2a6579e691e 100644 --- a/docker/blocks/prometheus/prometheus.yml +++ b/docker/blocks/prometheus/prometheus.yml @@ -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'] diff --git a/docker/blocks/prometheus2/docker-compose.yaml b/docker/blocks/prometheus2/docker-compose.yaml index 7b133ed9000..68c0358b7d0 100644 --- a/docker/blocks/prometheus2/docker-compose.yaml +++ b/docker/blocks/prometheus2/docker-compose.yaml @@ -23,3 +23,9 @@ network_mode: host ports: - "9093:9093" + + prometheus-random-data: + build: blocks/prometheus_random_data + network_mode: host + ports: + - "8080:8080" diff --git a/docker/blocks/prometheus2/prometheus.yml b/docker/blocks/prometheus2/prometheus.yml index 83dda78bb3c..57232aaa439 100644 --- a/docker/blocks/prometheus2/prometheus.yml +++ b/docker/blocks/prometheus2/prometheus.yml @@ -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'] diff --git a/docker/blocks/prometheus_random_data/Dockerfile b/docker/blocks/prometheus_random_data/Dockerfile new file mode 100644 index 00000000000..3aad497c94d --- /dev/null +++ b/docker/blocks/prometheus_random_data/Dockerfile @@ -0,0 +1,18 @@ +# This Dockerfile builds an image for a client_golang example. + +# Builder image, where we build the example. +FROM golang:1.9.0 AS builder +# Download prometheus/client_golang/examples/random first +RUN go get github.com/prometheus/client_golang/examples/random +WORKDIR /go/src/github.com/prometheus/client_golang +WORKDIR /go/src/github.com/prometheus/client_golang/prometheus +RUN go get -d +WORKDIR /go/src/github.com/prometheus/client_golang/examples/random +RUN CGO_ENABLED=0 GOOS=linux go build -a -tags netgo -ldflags '-w' + +# Final image. +FROM scratch +LABEL maintainer "The Prometheus Authors " +COPY --from=builder /go/src/github.com/prometheus/client_golang/examples/random . +EXPOSE 8080 +ENTRYPOINT ["/random"] diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index e955dbb9569..f7f8138b5e9 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -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 diff --git a/docs/sources/features/datasources/prometheus.md b/docs/sources/features/datasources/prometheus.md index 15247ba5ebd..c9bb16441ca 100644 --- a/docs/sources/features/datasources/prometheus.md +++ b/docs/sources/features/datasources/prometheus.md @@ -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. diff --git a/docs/sources/http_api/index.md b/docs/sources/http_api/index.md index 81b3c907c7a..2a74917a9fd 100644 --- a/docs/sources/http_api/index.md +++ b/docs/sources/http_api/index.md @@ -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" >}}) diff --git a/pkg/api/api.go b/pkg/api/api.go index 1b7e30e34ad..84f0eae79c7 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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) diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index ee2eba3b3b4..b861a344c75 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -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") } } diff --git a/pkg/plugins/update_checker.go b/pkg/plugins/update_checker.go index a2594ad915a..68ccdeaf840 100644 --- a/pkg/plugins/update_checker.go +++ b/pkg/plugins/update_checker.go @@ -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 } diff --git a/pkg/services/alerting/notifiers/pagerduty.go b/pkg/services/alerting/notifiers/pagerduty.go index c4067abec3b..6013648d9dd 100644 --- a/pkg/services/alerting/notifiers/pagerduty.go +++ b/pkg/services/alerting/notifiers/pagerduty.go @@ -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 { diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 6342496ed26..f449bec5849 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -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) diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 5ee34183628..8a89c3d942c 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -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 } diff --git a/public/app/core/specs/kbn.jest.ts b/public/app/core/specs/kbn.jest.ts index 2bd7c2a9e2d..9fad1f30694 100644 --- a/public/app/core/specs/kbn.jest.ts +++ b/public/app/core/specs/kbn.jest.ts @@ -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'); + }); +}); diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index ed8acb73cbb..3b78ccfc001 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -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' }, + ], + }, ]; }; diff --git a/public/app/core/utils/ticks.ts b/public/app/core/utils/ticks.ts index 834b7bd0cc4..db65104cfc0 100644 --- a/public/app/core/utils/ticks.ts +++ b/public/app/core/utils/ticks.ts @@ -156,3 +156,61 @@ export function getFlotTickDecimals(data, axis) { const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10); 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; + } +} diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 09bf53d05cf..a466e9b84d2 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -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); } diff --git a/public/app/plugins/datasource/prometheus/config_ctrl.ts b/public/app/plugins/datasource/prometheus/config_ctrl.ts new file mode 100644 index 00000000000..f7949ec9824 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/config_ctrl.ts @@ -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'; + } +} diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 122fe9601a1..4c736f2c664 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -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) { diff --git a/public/app/plugins/datasource/prometheus/module.ts b/public/app/plugins/datasource/prometheus/module.ts index e4292704916..d7e0b8ebe2c 100644 --- a/public/app/plugins/datasource/prometheus/module.ts +++ b/public/app/plugins/datasource/prometheus/module.ts @@ -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'; diff --git a/public/app/plugins/datasource/prometheus/partials/config.html b/public/app/plugins/datasource/prometheus/partials/config.html index 3bb43253d4d..2cd6adcbc4d 100644 --- a/public/app/plugins/datasource/prometheus/partials/config.html +++ b/public/app/plugins/datasource/prometheus/partials/config.html @@ -4,13 +4,23 @@
- Scrape interval - + Scrape interval + - 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.
-
+
+ +
+ +
+ + + Specify the HTTP Method to query Prometheus. (POST is only available in Prometheus >= v2.1.0) + +
+
diff --git a/public/app/plugins/datasource/prometheus/query_ctrl.ts b/public/app/plugins/datasource/prometheus/query_ctrl.ts index 2674fc55af4..ea5588b253a 100644 --- a/public/app/plugins/datasource/prometheus/query_ctrl.ts +++ b/public/app/plugins/datasource/prometheus/query_ctrl.ts @@ -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'; } diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts new file mode 100644 index 00000000000..6d97b783983 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -0,0 +1,199 @@ +import _ from 'lodash'; +import TableModel from 'app/core/table_model'; + +export class ResultTransformer { + constructor(private templateSrv) {} + + transform(result: any, response: any, options: any) { + let prometheusResult = response.data.data.result; + + if (options.format === 'table') { + result.push(this.transformMetricDataToTable(prometheusResult, options.responseListLength, options.responseIndex)); + } else if (options.format === 'heatmap') { + let seriesList = []; + prometheusResult.sort(sortSeriesByLabel); + for (let metricData of prometheusResult) { + seriesList.push(this.transformMetricData(metricData, options, options.start, options.end)); + } + seriesList = this.transformToHistogramOverTime(seriesList); + result.push(...seriesList); + } else { + for (let metricData of prometheusResult) { + if (response.data.data.resultType === 'matrix') { + result.push(this.transformMetricData(metricData, options, options.start, options.end)); + } else if (response.data.data.resultType === 'vector') { + result.push(this.transformInstantMetricData(metricData, options)); + } + } + } + } + + transformMetricData(md, options, start, end) { + let dps = [], + metricLabel = null; + + metricLabel = this.createMetricLabel(md.metric, options); + + const stepMs = parseInt(options.step) * 1000; + let baseTimestamp = start * 1000; + for (let value of md.values) { + let dp_value = parseFloat(value[1]); + if (_.isNaN(dp_value)) { + dp_value = null; + } + + const timestamp = parseFloat(value[0]) * 1000; + for (let t = baseTimestamp; t < timestamp; t += stepMs) { + dps.push([null, t]); + } + baseTimestamp = timestamp + stepMs; + dps.push([dp_value, timestamp]); + } + + const endTimestamp = end * 1000; + for (let t = baseTimestamp; t <= endTimestamp; t += stepMs) { + dps.push([null, t]); + } + + return { target: metricLabel, datapoints: dps }; + } + + transformMetricDataToTable(md, resultCount: number, resultIndex: number) { + var table = new TableModel(); + var i, j; + var metricLabels = {}; + + if (md.length === 0) { + return table; + } + + // Collect all labels across all metrics + _.each(md, function(series) { + for (var label in series.metric) { + if (!metricLabels.hasOwnProperty(label)) { + metricLabels[label] = 1; + } + } + }); + + // Sort metric labels, create columns for them and record their index + var sortedLabels = _.keys(metricLabels).sort(); + table.columns.push({ text: 'Time', type: 'time' }); + _.each(sortedLabels, function(label, labelIndex) { + metricLabels[label] = labelIndex + 1; + table.columns.push({ text: label }); + }); + let valueText = resultCount > 1 ? `Value #${String.fromCharCode(65 + resultIndex)}` : 'Value'; + table.columns.push({ text: valueText }); + + // Populate rows, set value to empty string when label not present. + _.each(md, function(series) { + if (series.value) { + series.values = [series.value]; + } + if (series.values) { + for (i = 0; i < series.values.length; i++) { + var values = series.values[i]; + var reordered: any = [values[0] * 1000]; + if (series.metric) { + for (j = 0; j < sortedLabels.length; j++) { + var label = sortedLabels[j]; + if (series.metric.hasOwnProperty(label)) { + reordered.push(series.metric[label]); + } else { + reordered.push(''); + } + } + } + reordered.push(parseFloat(values[1])); + table.rows.push(reordered); + } + } + }); + + return table; + } + + transformInstantMetricData(md, options) { + var dps = [], + metricLabel = null; + metricLabel = this.createMetricLabel(md.metric, options); + dps.push([parseFloat(md.value[1]), md.value[0] * 1000]); + return { target: metricLabel, datapoints: dps }; + } + + createMetricLabel(labelData, options) { + if (_.isUndefined(options) || _.isEmpty(options.legendFormat)) { + return this.getOriginalMetricName(labelData); + } + + return this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData) || '{}'; + } + + renderTemplate(aliasPattern, aliasData) { + var aliasRegex = /\{\{\s*(.+?)\s*\}\}/g; + return aliasPattern.replace(aliasRegex, function(match, g1) { + if (aliasData[g1]) { + return aliasData[g1]; + } + return g1; + }); + } + + getOriginalMetricName(labelData) { + var metricName = labelData.__name__ || ''; + delete labelData.__name__; + var labelPart = _.map(_.toPairs(labelData), function(label) { + return label[0] + '="' + label[1] + '"'; + }).join(','); + return metricName + '{' + labelPart + '}'; + } + + transformToHistogramOverTime(seriesList) { + /* t1 = timestamp1, t2 = timestamp2 etc. + t1 t2 t3 t1 t2 t3 + le10 10 10 0 => 10 10 0 + le20 20 10 30 => 10 0 30 + le30 30 10 35 => 10 0 5 + */ + for (let i = seriesList.length - 1; i > 0; i--) { + let topSeries = seriesList[i].datapoints; + let bottomSeries = seriesList[i - 1].datapoints; + for (let j = 0; j < topSeries.length; j++) { + topSeries[j][0] -= bottomSeries[j][0]; + } + } + + return seriesList; + } +} + +function sortSeriesByLabel(s1, s2): number { + let le1, le2; + + try { + // fail if not integer. might happen with bad queries + le1 = parseHistogramLabel(s1.metric.le); + le2 = parseHistogramLabel(s2.metric.le); + } catch (err) { + console.log(err); + return 0; + } + + if (le1 > le2) { + return 1; + } + + if (le1 < le2) { + return -1; + } + + return 0; +} + +function parseHistogramLabel(le: string): number { + if (le === '+Inf') { + return +Infinity; + } + return Number(le); +} diff --git a/public/app/plugins/datasource/prometheus/specs/datasource.jest.ts b/public/app/plugins/datasource/prometheus/specs/datasource.jest.ts new file mode 100644 index 00000000000..cca74e023e7 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/specs/datasource.jest.ts @@ -0,0 +1,104 @@ +import _ from 'lodash'; +import moment from 'moment'; +import q from 'q'; +import { PrometheusDatasource } from '../datasource'; + +describe('PrometheusDatasource', () => { + let ctx: any = {}; + let instanceSettings = { + url: 'proxied', + directUrl: 'direct', + user: 'test', + password: 'mupp', + jsonData: {}, + }; + + ctx.backendSrvMock = {}; + ctx.templateSrvMock = { + replace: a => a, + }; + ctx.timeSrvMock = {}; + + beforeEach(() => { + ctx.ds = new PrometheusDatasource(instanceSettings, q, ctx.backendSrvMock, ctx.templateSrvMock, ctx.timeSrvMock); + }); + + describe('When converting prometheus histogram to heatmap format', () => { + beforeEach(() => { + ctx.query = { + range: { from: moment(1443454528000), to: moment(1443454528000) }, + targets: [{ expr: 'test{job="testjob"}', format: 'heatmap', legendFormat: '{{le}}' }], + interval: '60s', + }; + }); + + it('should convert cumullative histogram to ordinary', () => { + const resultMock = [ + { + metric: { __name__: 'metric', job: 'testjob', le: '10' }, + values: [[1443454528.0, '10'], [1443454528.0, '10']], + }, + { + metric: { __name__: 'metric', job: 'testjob', le: '20' }, + values: [[1443454528.0, '20'], [1443454528.0, '10']], + }, + { + metric: { __name__: 'metric', job: 'testjob', le: '30' }, + values: [[1443454528.0, '25'], [1443454528.0, '10']], + }, + ]; + const responseMock = { data: { data: { result: resultMock } } }; + + const expected = [ + { + target: '10', + datapoints: [[10, 1443454528000], [10, 1443454528000]], + }, + { + target: '20', + datapoints: [[10, 1443454528000], [0, 1443454528000]], + }, + { + target: '30', + datapoints: [[5, 1443454528000], [0, 1443454528000]], + }, + ]; + + ctx.ds.performTimeSeriesQuery = jest.fn().mockReturnValue(responseMock); + return ctx.ds.query(ctx.query).then(result => { + let results = result.data; + return expect(results).toEqual(expected); + }); + }); + + it('should sort series by label value', () => { + const resultMock = [ + { + metric: { __name__: 'metric', job: 'testjob', le: '2' }, + values: [[1443454528.0, '10'], [1443454528.0, '10']], + }, + { + metric: { __name__: 'metric', job: 'testjob', le: '4' }, + values: [[1443454528.0, '20'], [1443454528.0, '10']], + }, + { + metric: { __name__: 'metric', job: 'testjob', le: '+Inf' }, + values: [[1443454528.0, '25'], [1443454528.0, '10']], + }, + { + metric: { __name__: 'metric', job: 'testjob', le: '1' }, + values: [[1443454528.0, '25'], [1443454528.0, '10']], + }, + ]; + const responseMock = { data: { data: { result: resultMock } } }; + + const expected = ['1', '2', '4', '+Inf']; + + ctx.ds.performTimeSeriesQuery = jest.fn().mockReturnValue(responseMock); + return ctx.ds.query(ctx.query).then(result => { + let seriesLabels = _.map(result.data, 'target'); + return expect(seriesLabels).toEqual(expected); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/specs/datasource_specs.ts b/public/app/plugins/datasource/prometheus/specs/datasource_specs.ts index 043bfcf25e0..35048416de6 100644 --- a/public/app/plugins/datasource/prometheus/specs/datasource_specs.ts +++ b/public/app/plugins/datasource/prometheus/specs/datasource_specs.ts @@ -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"}'); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/specs/metric_find_query_specs.ts b/public/app/plugins/datasource/prometheus/specs/metric_find_query_specs.ts index 3f7509fd0df..e5d7aa81210 100644 --- a/public/app/plugins/datasource/prometheus/specs/metric_find_query_specs.ts +++ b/public/app/plugins/datasource/prometheus/specs/metric_find_query_specs.ts @@ -12,7 +12,7 @@ describe('PrometheusMetricFindQuery', function() { directUrl: 'direct', user: 'test', password: 'mupp', - jsonData: {}, + jsonData: { httpMethod: 'GET' }, }; beforeEach(angularMocks.module('grafana.core')); diff --git a/public/app/plugins/datasource/prometheus/specs/result_transformer.jest.ts b/public/app/plugins/datasource/prometheus/specs/result_transformer.jest.ts new file mode 100644 index 00000000000..abcc46d7ea8 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/specs/result_transformer.jest.ts @@ -0,0 +1,118 @@ +import { ResultTransformer } from '../result_transformer'; + +describe('Prometheus Result Transformer', () => { + let ctx: any = {}; + + beforeEach(() => { + ctx.templateSrv = { + replace: str => str, + }; + ctx.resultTransformer = new ResultTransformer(ctx.templateSrv); + }); + + describe('When resultFormat is table', () => { + var response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + values: [[1443454528, '3846']], + }, + { + metric: { + __name__: 'test', + instance: 'localhost:8080', + job: 'otherjob', + }, + values: [[1443454529, '3847']], + }, + ], + }, + }; + + it('should return table model', () => { + var table = ctx.resultTransformer.transformMetricDataToTable(response.data.result); + expect(table.type).toBe('table'); + expect(table.rows).toEqual([ + [1443454528000, 'test', '', 'testjob', 3846], + [1443454529000, 'test', 'localhost:8080', 'otherjob', 3847], + ]); + expect(table.columns).toEqual([ + { text: 'Time', type: 'time' }, + { text: '__name__' }, + { text: 'instance' }, + { text: 'job' }, + { text: 'Value' }, + ]); + }); + }); + + describe('When resultFormat is table and instant = true', () => { + var response = { + status: 'success', + data: { + resultType: 'vector', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + value: [1443454528, '3846'], + }, + ], + }, + }; + + it('should return table model', () => { + var table = ctx.resultTransformer.transformMetricDataToTable(response.data.result); + expect(table.type).toBe('table'); + expect(table.rows).toEqual([[1443454528000, 'test', 'testjob', 3846]]); + expect(table.columns).toEqual([ + { text: 'Time', type: 'time' }, + { text: '__name__' }, + { text: 'job' }, + { text: 'Value' }, + ]); + }); + }); + + describe('When resultFormat is heatmap', () => { + var response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob', le: '1' }, + values: [[1445000010, '10'], [1445000020, '10'], [1445000030, '0']], + }, + { + metric: { __name__: 'test', job: 'testjob', le: '2' }, + values: [[1445000010, '20'], [1445000020, '10'], [1445000030, '30']], + }, + { + metric: { __name__: 'test', job: 'testjob', le: '3' }, + values: [[1445000010, '30'], [1445000020, '10'], [1445000030, '40']], + }, + ], + }, + }; + + it('should convert cumulative histogram to regular', () => { + let result = []; + let options = { + format: 'heatmap', + start: 1445000010, + end: 1445000030, + legendFormat: '{{le}}', + }; + + ctx.resultTransformer.transform(result, { data: response }, options); + expect(result).toEqual([ + { target: '1', datapoints: [[10, 1445000010000], [10, 1445000020000], [0, 1445000030000]] }, + { target: '2', datapoints: [[10, 1445000010000], [0, 1445000020000], [30, 1445000030000]] }, + { target: '3', datapoints: [[10, 1445000010000], [0, 1445000020000], [10, 1445000030000]] }, + ]); + }); + }); +}); diff --git a/public/app/plugins/panel/graph/data_processor.ts b/public/app/plugins/panel/graph/data_processor.ts index 2930c871bc9..f8162c57a10 100644 --- a/public/app/plugins/panel/graph/data_processor.ts +++ b/public/app/plugins/panel/graph/data_processor.ts @@ -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); }); diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 3ed8cbc1836..369f37022f4 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -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); diff --git a/public/app/plugins/panel/graph/histogram.ts b/public/app/plugins/panel/graph/histogram.ts index 8b2be9efcf7..ad56e477a85 100644 --- a/public/app/plugins/panel/graph/histogram.ts +++ b/public/app/plugins/panel/graph/histogram.ts @@ -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; } diff --git a/public/app/plugins/panel/graph/specs/graph_specs.ts b/public/app/plugins/panel/graph/specs/graph_specs.ts index 176bb65b806..d29320a9d72 100644 --- a/public/app/plugins/panel/graph/specs/graph_specs.ts +++ b/public/app/plugins/panel/graph/specs/graph_specs.ts @@ -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); + }); + }); }); diff --git a/public/app/plugins/panel/graph/specs/histogram.jest.ts b/public/app/plugins/panel/graph/specs/histogram.jest.ts index 4f0ca472375..0e9eaa8b98e 100644 --- a/public/app/plugins/panel/graph/specs/histogram.jest.ts +++ b/public/app/plugins/panel/graph/specs/histogram.jest.ts @@ -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); }); }); diff --git a/public/app/plugins/panel/graph/tab_display.html b/public/app/plugins/panel/graph/tab_display.html index 2ab5418c6c2..ebc6cf9b18e 100644 --- a/public/app/plugins/panel/graph/tab_display.html +++ b/public/app/plugins/panel/graph/tab_display.html @@ -71,7 +71,7 @@
Stacking & Null value
- + diff --git a/public/app/plugins/panel/heatmap/axes_editor.ts b/public/app/plugins/panel/heatmap/axes_editor.ts index 05ed63e0289..81df957e2ea 100644 --- a/public/app/plugins/panel/heatmap/axes_editor.ts +++ b/public/app/plugins/panel/heatmap/axes_editor.ts @@ -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) { diff --git a/public/app/plugins/panel/heatmap/heatmap_ctrl.ts b/public/app/plugins/panel/heatmap/heatmap_ctrl.ts index c47af640af2..11fbad47b99 100644 --- a/public/app/plugins/panel/heatmap/heatmap_ctrl.ts +++ b/public/app/plugins/panel/heatmap/heatmap_ctrl.ts @@ -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)); diff --git a/public/app/plugins/panel/heatmap/heatmap_data_converter.ts b/public/app/plugins/panel/heatmap/heatmap_data_converter.ts index bccd62235f5..048b19de911 100644 --- a/public/app/plugins/panel/heatmap/heatmap_data_converter.ts +++ b/public/app/plugins/panel/heatmap/heatmap_data_converter.ts @@ -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, }; diff --git a/public/app/plugins/panel/heatmap/heatmap_tooltip.ts b/public/app/plugins/panel/heatmap/heatmap_tooltip.ts index 823c56425a5..1caa9ae9c69 100644 --- a/public/app/plugins/panel/heatmap/heatmap_tooltip.ts +++ b/public/app/plugins/panel/heatmap/heatmap_tooltip.ts @@ -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 = `
${time}
@@ -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 += `
bucket: ${boundBottom} - ${boundTop}
count: ${valuesNumber}
@@ -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); }; diff --git a/public/app/plugins/panel/heatmap/partials/axes_editor.html b/public/app/plugins/panel/heatmap/partials/axes_editor.html index 5d25bedd667..0b12ac52e24 100644 --- a/public/app/plugins/panel/heatmap/partials/axes_editor.html +++ b/public/app/plugins/panel/heatmap/partials/axes_editor.html @@ -9,25 +9,36 @@ dropdown-typeahead-on-select="editor.setUnitFormat($subItem)">
-
- -
- +
+
+ +
+ +
+
+
+ + +
+
+ +
-
-
- - -
-
- -
+ bs-tooltip="'Override automatic decimal precision for axis.'" + ng-model="ctrl.panel.yAxis.decimals" ng-change="ctrl.render()" ng-model-onblur> +
+
+ +
+ +
@@ -82,7 +93,9 @@
- +
diff --git a/public/app/plugins/panel/heatmap/rendering.ts b/public/app/plugins/panel/heatmap/rendering.ts index 0977279809a..54d17146532 100644 --- a/public/app/plugins/panel/heatmap/rendering.ts +++ b/public/app/plugins/panel/heatmap/rendering.ts @@ -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; - } -} diff --git a/public/app/plugins/panel/heatmap/specs/heatmap_data_converter.jest.ts b/public/app/plugins/panel/heatmap/specs/heatmap_data_converter.jest.ts index 76bf7c39538..b6a8713a3e9 100644 --- a/public/app/plugins/panel/heatmap/specs/heatmap_data_converter.jest.ts +++ b/public/app/plugins/panel/heatmap/specs/heatmap_data_converter.jest.ts @@ -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); + }); }); }); diff --git a/public/app/plugins/panel/heatmap/specs/renderer_specs.ts b/public/app/plugins/panel/heatmap/specs/renderer_specs.ts index 88e4953936b..f52b6d1d985 100644 --- a/public/app/plugins/panel/heatmap/specs/renderer_specs.ts +++ b/public/app/plugins/panel/heatmap/specs/renderer_specs.ts @@ -8,7 +8,7 @@ import TimeSeries from 'app/core/time_series2'; import moment from 'moment'; import { 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) { diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index 611a9a55287..776033536dd 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -426,14 +426,16 @@ class SingleStatCtrl extends MetricsPanelCtrl { var body = '
'; 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 += '
';