mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into sql-proxy
This commit is contained in:
commit
26804d630f
@ -15,6 +15,10 @@
|
||||
* **Cloudwatch**: Correctly obtain IAM roles within ECS container tasks [#7892](https://github.com/grafana/grafana/issues/7892) thx [@gomlgs](https://github.com/gomlgs)
|
||||
* **Units**: New number format: Scientific notation [#7781](https://github.com/grafana/grafana/issues/7781) thx [@cadnce](https://github.com/cadnce)
|
||||
* **Oauth**: Add common type for oauth authorization errors [#6428](https://github.com/grafana/grafana/issues/6428) thx [@amenzhinsky](https://github.com/amenzhinsky)
|
||||
* **Templating**: Data source variable now supports multi value and panel repeats [#7030](https://github.com/grafana/grafana/issues/7030) thx [@mtanda](https://github.com/mtanda)
|
||||
|
||||
## Fixes
|
||||
* **Table Panel**: Fixed annotation display in table panel, [#8023](https://github.com/grafana/grafana/issues/8023)
|
||||
|
||||
# 4.2.0 (2017-03-22)
|
||||
## Minor Enhancements
|
||||
|
@ -60,14 +60,14 @@ cert_key =
|
||||
#################################### Database ############################
|
||||
[database]
|
||||
# You can configure the database connection by specifying type, host, name, user and password
|
||||
# as seperate properties or as on string using the url propertie.
|
||||
# as separate properties or as on string using the url property.
|
||||
|
||||
# Either "mysql", "postgres" or "sqlite3", it's your choice
|
||||
type = sqlite3
|
||||
host = 127.0.0.1:3306
|
||||
name = grafana
|
||||
user = root
|
||||
# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;"""
|
||||
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
|
||||
password =
|
||||
# Use either URL or the previous fields to configure the database
|
||||
# Example: mysql://user:secret@host:port/database
|
||||
@ -132,7 +132,7 @@ logging = false
|
||||
reporting_enabled = true
|
||||
|
||||
# Set to false to disable all checks to https://grafana.com
|
||||
# for new vesions (grafana itself and plugins), check is used
|
||||
# for new versions (grafana itself and plugins), check is used
|
||||
# in some UI views to notify that grafana or plugin update exists
|
||||
# This option does not cause any auto updates, nor send any information
|
||||
# only a GET request to https://grafana.com to get latest versions
|
||||
|
@ -14,7 +14,7 @@ start_tls = false
|
||||
# set to true if you want to skip ssl cert validation
|
||||
ssl_skip_verify = false
|
||||
# set to the path to your root CA certificate or leave unset to use system defaults
|
||||
# root_ca_cert = /path/to/certificate.crt
|
||||
# root_ca_cert = "/path/to/certificate.crt"
|
||||
|
||||
# Search user bind dn
|
||||
bind_dn = "cn=admin,dc=grafana,dc=org"
|
||||
|
@ -232,7 +232,7 @@ Get all tags of dashboards
|
||||
Status Codes:
|
||||
|
||||
- **query** – Search Query
|
||||
- **tags** – Tags to use
|
||||
- **tag** – Tag to use
|
||||
- **starred** – Flag indicating if only starred Dashboards should be returned
|
||||
- **tagcloud** - Flag indicating if a tagcloud should be returned
|
||||
|
||||
|
@ -38,7 +38,7 @@ start_tls = false
|
||||
# set to true if you want to skip ssl cert validation
|
||||
ssl_skip_verify = false
|
||||
# set to the path to your root CA certificate or leave unset to use system defaults
|
||||
# root_ca_cert = /path/to/certificate.crt
|
||||
# root_ca_cert = "/path/to/certificate.crt"
|
||||
|
||||
# Search user bind dn
|
||||
bind_dn = "cn=admin,dc=grafana,dc=org"
|
||||
|
@ -17,7 +17,7 @@ There are two blog posts about authoring a plugin that might also be of interest
|
||||
## Short version
|
||||
|
||||
1. [Setup grafana](http://docs.grafana.org/project/building_from_source/)
|
||||
2. Clone an example plugin into ```/var/lib/grafana/plugins``` or `data/plugins` (relative to grafana git repo if your running development version from source dir)
|
||||
2. Clone an example plugin into ```/var/lib/grafana/plugins``` or `data/plugins` (relative to grafana git repo if you're running development version from source dir)
|
||||
3. Code away!
|
||||
|
||||
## What languages?
|
||||
|
@ -1,4 +1,4 @@
|
||||
{
|
||||
"stable": "4.1.1",
|
||||
"testing": "4.1.1"
|
||||
"stable": "4.2.0",
|
||||
"testing": "4.2.0"
|
||||
}
|
||||
|
@ -198,6 +198,9 @@ func LoadConfig() {
|
||||
|
||||
if DbCfg.Type == "sqlite3" {
|
||||
UseSQLite3 = true
|
||||
// only allow one connection as sqlite3 has multi threading issues that casue table locks
|
||||
// DbCfg.MaxIdleConn = 1
|
||||
// DbCfg.MaxOpenConn = 1
|
||||
}
|
||||
DbCfg.SslMode = sec.Key("ssl_mode").String()
|
||||
DbCfg.CaCertPath = sec.Key("ca_cert_path").String()
|
||||
|
@ -34,13 +34,18 @@ func (query *Query) Build(queryContext *tsdb.QueryContext) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
res = strings.Replace(res, "$timeFilter", query.renderTimeFilter(queryContext), 1)
|
||||
res = strings.Replace(res, "$interval", interval.Text, 1)
|
||||
res = strings.Replace(res, "$__interval_ms", strconv.FormatInt(interval.Value.Nanoseconds()/int64(time.Millisecond), 10), 1)
|
||||
res = strings.Replace(res, "$__interval", interval.Text, 1)
|
||||
res = replaceVariable(res, "$timeFilter", query.renderTimeFilter(queryContext))
|
||||
res = replaceVariable(res, "$interval", interval.Text)
|
||||
res = replaceVariable(res, "$__interval_ms", strconv.FormatInt(interval.Value.Nanoseconds()/int64(time.Millisecond), 10))
|
||||
res = replaceVariable(res, "$__interval", interval.Text)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func replaceVariable(str string, variable string, value string) string {
|
||||
count := strings.Count(str, variable)
|
||||
return strings.Replace(str, variable, value, count)
|
||||
}
|
||||
|
||||
func getDefinedInterval(query *Query, queryContext *tsdb.QueryContext) (*tsdb.Interval, error) {
|
||||
defaultInterval := tsdb.CalculateInterval(queryContext.TimeRange)
|
||||
|
||||
|
@ -11,6 +11,7 @@ export class User {
|
||||
orgRole: any;
|
||||
timezone: string;
|
||||
helpFlags1: number;
|
||||
lightTheme: boolean;
|
||||
|
||||
constructor() {
|
||||
if (config.bootData.user) {
|
||||
|
27
public/app/core/utils/ticks.ts
Normal file
27
public/app/core/utils/ticks.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Calculate tick step.
|
||||
* Implementation from d3-array (ticks.js)
|
||||
* https://github.com/d3/d3-array/blob/master/src/ticks.js
|
||||
* @param start Start value
|
||||
* @param stop End value
|
||||
* @param count Ticks count
|
||||
*/
|
||||
export function tickStep(start: number, stop: number, count: number): number {
|
||||
let e10 = Math.sqrt(50),
|
||||
e5 = Math.sqrt(10),
|
||||
e2 = Math.sqrt(2);
|
||||
|
||||
let step0 = Math.abs(stop - start) / Math.max(0, count),
|
||||
step1 = Math.pow(10, Math.floor(Math.log(step0) / Math.LN10)),
|
||||
error = step0 / step1;
|
||||
|
||||
if (error >= e10) {
|
||||
step1 *= 10;
|
||||
} else if (error >= e5) {
|
||||
step1 *= 5;
|
||||
} else if (error >= e2) {
|
||||
step1 *= 2;
|
||||
}
|
||||
|
||||
return stop < start ? -step1 : step1;
|
||||
}
|
@ -129,7 +129,7 @@
|
||||
</div>
|
||||
<div class="gf-form gf-form--v-stretch">
|
||||
<span class="gf-form-label width-8">Message</span>
|
||||
<textarea class="gf-form-input width-20" rows="10" ng-model="ctrl.alert.message" placeholder="Notification message details..."></textarea>
|
||||
<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message" placeholder="Notification message details..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -45,7 +45,9 @@ export class AdhocVariable implements Variable {
|
||||
}
|
||||
|
||||
this.filters = urlValue.map(item => {
|
||||
var values = item.split('|');
|
||||
var values = item.split('|').map(value => {
|
||||
return this.unescapeDelimiter(value);
|
||||
});
|
||||
return {
|
||||
key: values[0],
|
||||
operator: values[1],
|
||||
@ -58,10 +60,20 @@ export class AdhocVariable implements Variable {
|
||||
|
||||
getValueForUrl() {
|
||||
return this.filters.map(filter => {
|
||||
return filter.key + '|' + filter.operator + '|' + filter.value;
|
||||
return [filter.key, filter.operator, filter.value].map(value => {
|
||||
return this.escapeDelimiter(value);
|
||||
}).join('|');
|
||||
});
|
||||
}
|
||||
|
||||
escapeDelimiter(value) {
|
||||
return value.replace('|', '__gfp__');
|
||||
}
|
||||
|
||||
unescapeDelimiter(value) {
|
||||
return value.replace('__gfp__', '|');
|
||||
}
|
||||
|
||||
setFilters(filters: any[]) {
|
||||
this.filters = filters;
|
||||
}
|
||||
|
@ -11,10 +11,11 @@ describe('AdhocVariable', function() {
|
||||
filters: [
|
||||
{key: 'key1', operator: '=', value: 'value1'},
|
||||
{key: 'key2', operator: '!=', value: 'value2'},
|
||||
{key: 'key3', operator: '=', value: 'value3a|value3b'},
|
||||
]
|
||||
});
|
||||
var urlValue = variable.getValueForUrl();
|
||||
expect(urlValue).to.eql(["key1|=|value1", "key2|!=|value2"]);
|
||||
expect(urlValue).to.eql(["key1|=|value1", "key2|!=|value2", "key3|=|value3a__gfp__value3b"]);
|
||||
});
|
||||
|
||||
});
|
||||
@ -23,7 +24,7 @@ describe('AdhocVariable', function() {
|
||||
|
||||
it('should restore filters', function() {
|
||||
var variable = new AdhocVariable({});
|
||||
variable.setValueFromUrl(["key1|=|value1", "key2|!=|value2"]);
|
||||
variable.setValueFromUrl(["key1|=|value1", "key2|!=|value2", "key3|=|value3a__gfp__value3b"]);
|
||||
|
||||
expect(variable.filters[0].key).to.be('key1');
|
||||
expect(variable.filters[0].operator).to.be('=');
|
||||
@ -32,6 +33,10 @@ describe('AdhocVariable', function() {
|
||||
expect(variable.filters[1].key).to.be('key2');
|
||||
expect(variable.filters[1].operator).to.be('!=');
|
||||
expect(variable.filters[1].value).to.be('value2');
|
||||
|
||||
expect(variable.filters[2].key).to.be('key3');
|
||||
expect(variable.filters[2].operator).to.be('=');
|
||||
expect(variable.filters[2].value).to.be('value3a|value3b');
|
||||
});
|
||||
|
||||
});
|
||||
|
5
public/app/headers/common.d.ts
vendored
5
public/app/headers/common.d.ts
vendored
@ -67,3 +67,8 @@ declare module 'remarkable' {
|
||||
var config: any;
|
||||
export default config;
|
||||
}
|
||||
|
||||
declare module 'd3' {
|
||||
var d3: any;
|
||||
export default d3;
|
||||
}
|
||||
|
@ -39,10 +39,10 @@
|
||||
|
||||
<div class="section gf-form-group">
|
||||
<h5 class="section-heading">X-Axis</h5>
|
||||
<gf-form-switch class="gf-form" label="Show" label-class="width-5" checked="ctrl.panel.xaxis.show" on-change="ctrl.render()"></gf-form-switch>
|
||||
<gf-form-switch class="gf-form" label="Show" label-class="width-6" checked="ctrl.panel.xaxis.show" on-change="ctrl.render()"></gf-form-switch>
|
||||
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-5">Mode</label>
|
||||
<label class="gf-form-label width-6">Mode</label>
|
||||
<div class="gf-form-select-wrapper max-width-15">
|
||||
<select class="gf-form-input" ng-model="ctrl.panel.xaxis.mode" ng-options="v as k for (k, v) in ctrl.xAxisModes" ng-change="ctrl.xAxisOptionChanged()"> </select>
|
||||
</div>
|
||||
@ -50,22 +50,28 @@
|
||||
|
||||
<!-- Table mode -->
|
||||
<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'field'">
|
||||
<label class="gf-form-label width-5">Name</label>
|
||||
<label class="gf-form-label width-6">Name</label>
|
||||
<metric-segment-model property="ctrl.panel.xaxis.name" get-options="ctrl.getDataFieldNames(false)" on-change="ctrl.xAxisOptionChanged()" custom="false" css-class="width-10" select-mode="true"></metric-segment-model>
|
||||
</div>
|
||||
|
||||
<!-- Series mode -->
|
||||
<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'field'">
|
||||
<label class="gf-form-label width-5">Value</label>
|
||||
<label class="gf-form-label width-6">Value</label>
|
||||
<metric-segment-model property="ctrl.panel.xaxis.values[0]" get-options="ctrl.getDataFieldNames(true)" on-change="ctrl.xAxisOptionChanged()" custom="false" css-class="width-10" select-mode="true"></metric-segment-model>
|
||||
</div>
|
||||
|
||||
<!-- Series mode -->
|
||||
<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'series'">
|
||||
<label class="gf-form-label width-5">Value</label>
|
||||
<label class="gf-form-label width-6">Value</label>
|
||||
<metric-segment-model property="ctrl.panel.xaxis.values[0]" options="ctrl.xAxisStatOptions" on-change="ctrl.xAxisOptionChanged()" custom="false" css-class="width-10" select-mode="true"></metric-segment-model>
|
||||
</div>
|
||||
|
||||
<!-- Histogram mode -->
|
||||
<div class="gf-form" ng-if="ctrl.panel.xaxis.mode === 'histogram'">
|
||||
<label class="gf-form-label width-6">Buckets</label>
|
||||
<input type="number" class="gf-form-input max-width-8" ng-model="ctrl.panel.xaxis.buckets" placeholder="auto" ng-change="ctrl.render()" ng-model-onblur bs-tooltip="'Number of buckets'" data-placement="right">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@ -30,6 +30,7 @@ export class AxesEditorCtrl {
|
||||
this.xAxisModes = {
|
||||
'Time': 'time',
|
||||
'Series': 'series',
|
||||
'Histogram': 'histogram'
|
||||
// 'Data field': 'field',
|
||||
};
|
||||
|
||||
|
@ -29,6 +29,7 @@ export class DataProcessor {
|
||||
|
||||
switch (this.panel.xaxis.mode) {
|
||||
case 'series':
|
||||
case 'histogram':
|
||||
case 'time': {
|
||||
return options.dataList.map((item, index) => {
|
||||
return this.timeSeriesHandler(item, index, options);
|
||||
@ -48,6 +49,9 @@ export class DataProcessor {
|
||||
if (this.panel.xaxis.mode === 'series') {
|
||||
return 'series';
|
||||
}
|
||||
if (this.panel.xaxis.mode === 'histogram') {
|
||||
return 'histogram';
|
||||
}
|
||||
return 'time';
|
||||
}
|
||||
}
|
||||
@ -74,6 +78,15 @@ export class DataProcessor {
|
||||
this.panel.xaxis.values = ['total'];
|
||||
break;
|
||||
}
|
||||
case 'histogram': {
|
||||
this.panel.bars = true;
|
||||
this.panel.lines = false;
|
||||
this.panel.points = false;
|
||||
this.panel.stack = false;
|
||||
this.panel.legend.show = false;
|
||||
this.panel.tooltip.shared = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,10 +12,12 @@ import './jquery.flot.events';
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import {tickStep} from 'app/core/utils/ticks';
|
||||
import {appEvents, coreModule} from 'app/core/core';
|
||||
import GraphTooltip from './graph_tooltip';
|
||||
import {ThresholdManager} from './threshold_manager';
|
||||
import {convertValuesToHistogram, getSeriesValues} from './histogram';
|
||||
|
||||
coreModule.directive('grafanaGraph', function($rootScope, timeSrv) {
|
||||
return {
|
||||
@ -290,6 +292,29 @@ coreModule.directive('grafanaGraph', function($rootScope, timeSrv) {
|
||||
addXSeriesAxis(options);
|
||||
break;
|
||||
}
|
||||
case 'histogram': {
|
||||
let bucketSize: number;
|
||||
let values = getSeriesValues(data);
|
||||
|
||||
if (data.length && values.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;
|
||||
data[0].alias = data[0].label = data[0].id = "count";
|
||||
data = [data[0]];
|
||||
|
||||
options.series.bars.barWidth = bucketSize * 0.8;
|
||||
} else {
|
||||
bucketSize = 0;
|
||||
}
|
||||
|
||||
addXHistogramAxis(options, bucketSize);
|
||||
break;
|
||||
}
|
||||
case 'table': {
|
||||
options.series.bars.barWidth = 0.7;
|
||||
options.series.bars.align = 'center';
|
||||
@ -384,6 +409,38 @@ coreModule.directive('grafanaGraph', function($rootScope, timeSrv) {
|
||||
};
|
||||
}
|
||||
|
||||
function addXHistogramAxis(options, bucketSize) {
|
||||
let ticks, min, max;
|
||||
|
||||
if (data.length) {
|
||||
ticks = _.map(data[0].data, point => point[0]);
|
||||
|
||||
// Expand ticks for pretty view
|
||||
min = Math.max(0, _.min(ticks) - bucketSize);
|
||||
max = _.max(ticks) + bucketSize;
|
||||
|
||||
ticks = [];
|
||||
for (let i = min; i <= max; i += bucketSize) {
|
||||
ticks.push(i);
|
||||
}
|
||||
} else {
|
||||
// Set defaults if no data
|
||||
ticks = panelWidth / 100;
|
||||
min = 0;
|
||||
max = 1;
|
||||
}
|
||||
|
||||
options.xaxis = {
|
||||
timezone: dashboard.getTimezone(),
|
||||
show: panel.xaxis.show,
|
||||
mode: null,
|
||||
min: min,
|
||||
max: max,
|
||||
label: "Histogram",
|
||||
ticks: ticks
|
||||
};
|
||||
}
|
||||
|
||||
function addXTableAxis(options) {
|
||||
var ticks = _.map(data, function(series, seriesIndex) {
|
||||
return _.map(series.datapoints, function(point, pointIndex) {
|
||||
|
48
public/app/plugins/panel/graph/histogram.ts
Normal file
48
public/app/plugins/panel/graph/histogram.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
/**
|
||||
* Convert series into array of series values.
|
||||
* @param data Array of series
|
||||
*/
|
||||
export function getSeriesValues(data: any): number[] {
|
||||
let values = [];
|
||||
|
||||
// Count histogam stats
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
let series = data[i];
|
||||
for (let j = 0; j < series.data.length; j++) {
|
||||
if (series.data[j][1] !== null) {
|
||||
values.push(series.data[j][1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array of values into timeseries-like histogram:
|
||||
* [[val_1, count_1], [val_2, count_2], ..., [val_n, count_n]]
|
||||
* @param values
|
||||
* @param bucketSize
|
||||
*/
|
||||
export function convertValuesToHistogram(values: number[], bucketSize: number): any[] {
|
||||
let histogram = {};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return _.map(histogram, (count, bound) => {
|
||||
return [Number(bound), count];
|
||||
});
|
||||
}
|
||||
|
||||
function getBucketBound(value: number, bucketSize: number): number {
|
||||
return Math.floor(value / bucketSize) * bucketSize;
|
||||
}
|
@ -59,6 +59,7 @@ class GraphCtrl extends MetricsPanelCtrl {
|
||||
mode: 'time',
|
||||
name: null,
|
||||
values: [],
|
||||
buckets: null
|
||||
},
|
||||
// show/hide lines
|
||||
lines : true,
|
||||
|
65
public/app/plugins/panel/graph/specs/histogram_specs.ts
Normal file
65
public/app/plugins/panel/graph/specs/histogram_specs.ts
Normal file
@ -0,0 +1,65 @@
|
||||
///<reference path="../../../../headers/common.d.ts" />
|
||||
|
||||
import { describe, beforeEach, it, expect } from '../../../../../test/lib/common';
|
||||
|
||||
import { convertValuesToHistogram, getSeriesValues } from '../histogram';
|
||||
|
||||
describe('Graph Histogam Converter', function () {
|
||||
|
||||
describe('Values to histogram converter', () => {
|
||||
let values;
|
||||
let bucketSize = 10;
|
||||
|
||||
beforeEach(() => {
|
||||
values = [1, 2, 10, 11, 17, 20, 29];
|
||||
});
|
||||
|
||||
it('Should convert to series-like array', () => {
|
||||
bucketSize = 10;
|
||||
let expected = [
|
||||
[0, 2], [10, 3], [20, 2]
|
||||
];
|
||||
|
||||
let histogram = convertValuesToHistogram(values, bucketSize);
|
||||
expect(histogram).to.eql(expected);
|
||||
});
|
||||
|
||||
it('Should not add empty buckets', () => {
|
||||
bucketSize = 5;
|
||||
let expected = [
|
||||
[0, 2], [10, 2], [15, 1], [20, 1], [25, 1]
|
||||
];
|
||||
|
||||
let histogram = convertValuesToHistogram(values, bucketSize);
|
||||
expect(histogram).to.eql(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Series to values converter', () => {
|
||||
let data;
|
||||
|
||||
beforeEach(() => {
|
||||
data = [
|
||||
{
|
||||
data: [[0, 1], [0, 2], [0, 10], [0, 11], [0, 17], [0, 20], [0, 29]]
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
it('Should convert to values array', () => {
|
||||
let expected = [1, 2, 10, 11, 17, 20, 29];
|
||||
|
||||
let values = getSeriesValues(data);
|
||||
expect(values).to.eql(expected);
|
||||
});
|
||||
|
||||
it('Should skip null values', () => {
|
||||
data[0].data.push([0, null]);
|
||||
|
||||
let expected = [1, 2, 10, 11, 17, 20, 29];
|
||||
|
||||
let values = getSeriesValues(data);
|
||||
expect(values).to.eql(expected);
|
||||
});
|
||||
});
|
||||
});
|
0
public/app/plugins/panel/heatmap/README.md
Normal file
0
public/app/plugins/panel/heatmap/README.md
Normal file
49
public/app/plugins/panel/heatmap/axes_editor.ts
Normal file
49
public/app/plugins/panel/heatmap/axes_editor.ts
Normal file
@ -0,0 +1,49 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
|
||||
export class AxesEditorCtrl {
|
||||
panel: any;
|
||||
panelCtrl: any;
|
||||
unitFormats: any;
|
||||
logScales: any;
|
||||
dataFormats: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope) {
|
||||
$scope.editor = this;
|
||||
this.panelCtrl = $scope.ctrl;
|
||||
this.panel = this.panelCtrl.panel;
|
||||
|
||||
this.unitFormats = kbn.getUnitFormats();
|
||||
|
||||
this.logScales = {
|
||||
'linear': 1,
|
||||
'log (base 2)': 2,
|
||||
'log (base 10)': 10,
|
||||
'log (base 32)': 32,
|
||||
'log (base 1024)': 1024
|
||||
};
|
||||
|
||||
this.dataFormats = {
|
||||
'Timeseries': 'timeseries',
|
||||
'ES histogram': 'es_histogram'
|
||||
};
|
||||
}
|
||||
|
||||
setUnitFormat(subItem) {
|
||||
this.panel.yAxis.format = subItem.value;
|
||||
this.panelCtrl.render();
|
||||
}
|
||||
}
|
||||
|
||||
/** @ngInject */
|
||||
export function axesEditor() {
|
||||
'use strict';
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: true,
|
||||
templateUrl: 'public/app/plugins/panel/heatmap/partials/axes_editor.html',
|
||||
controller: AxesEditorCtrl,
|
||||
};
|
||||
}
|
26
public/app/plugins/panel/heatmap/display_editor.ts
Normal file
26
public/app/plugins/panel/heatmap/display_editor.ts
Normal file
@ -0,0 +1,26 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
export class HeatmapDisplayEditorCtrl {
|
||||
panel: any;
|
||||
panelCtrl: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope) {
|
||||
$scope.editor = this;
|
||||
this.panelCtrl = $scope.ctrl;
|
||||
this.panel = this.panelCtrl.panel;
|
||||
|
||||
this.panelCtrl.render();
|
||||
}
|
||||
}
|
||||
|
||||
/** @ngInject */
|
||||
export function heatmapDisplayEditor() {
|
||||
'use strict';
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: true,
|
||||
templateUrl: 'public/app/plugins/panel/heatmap/partials/display_editor.html',
|
||||
controller: HeatmapDisplayEditorCtrl,
|
||||
};
|
||||
}
|
282
public/app/plugins/panel/heatmap/heatmap_ctrl.ts
Normal file
282
public/app/plugins/panel/heatmap/heatmap_ctrl.ts
Normal file
@ -0,0 +1,282 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import {MetricsPanelCtrl} from 'app/plugins/sdk';
|
||||
import _ from 'lodash';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import TimeSeries from 'app/core/time_series';
|
||||
import {axesEditor} from './axes_editor';
|
||||
import {heatmapDisplayEditor} from './display_editor';
|
||||
import rendering from './rendering';
|
||||
import { convertToHeatMap, elasticHistogramToHeatmap, calculateBucketSize, getMinLog} from './heatmap_data_converter';
|
||||
|
||||
let X_BUCKET_NUMBER_DEFAULT = 30;
|
||||
let Y_BUCKET_NUMBER_DEFAULT = 10;
|
||||
|
||||
let panelDefaults = {
|
||||
heatmap: {
|
||||
},
|
||||
cards: {
|
||||
cardPadding: null,
|
||||
cardRound: null
|
||||
},
|
||||
color: {
|
||||
mode: 'spectrum',
|
||||
cardColor: '#b4ff00',
|
||||
colorScale: 'sqrt',
|
||||
exponent: 0.5,
|
||||
colorScheme: 'interpolateOranges',
|
||||
fillBackground: false
|
||||
},
|
||||
dataFormat: 'timeseries',
|
||||
xBucketSize: null,
|
||||
xBucketNumber: null,
|
||||
yBucketSize: null,
|
||||
yBucketNumber: null,
|
||||
xAxis: {
|
||||
show: true
|
||||
},
|
||||
yAxis: {
|
||||
show: true,
|
||||
format: 'short',
|
||||
decimals: null,
|
||||
logBase: 1,
|
||||
splitFactor: null,
|
||||
min: null,
|
||||
max: null,
|
||||
removeZeroValues: false
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
seriesStat: false,
|
||||
showHistogram: false
|
||||
},
|
||||
highlightCards: true
|
||||
};
|
||||
|
||||
let colorModes = ['opacity', 'spectrum'];
|
||||
let opacityScales = ['linear', 'sqrt'];
|
||||
|
||||
// Schemes from d3-scale-chromatic
|
||||
// https://github.com/d3/d3-scale-chromatic
|
||||
let colorSchemes = [
|
||||
// Diverging
|
||||
{name: 'Spectral', value: 'interpolateSpectral', invert: 'always'},
|
||||
{name: 'RdYlGn', value: 'interpolateRdYlGn', invert: 'always'},
|
||||
|
||||
// Sequential (Single Hue)
|
||||
{name: 'Blues', value: 'interpolateBlues', invert: 'dark'},
|
||||
{name: 'Greens', value: 'interpolateGreens', invert: 'dark'},
|
||||
{name: 'Greys', value: 'interpolateGreys', invert: 'dark'},
|
||||
{name: 'Oranges', value: 'interpolateOranges', invert: 'dark'},
|
||||
{name: 'Purples', value: 'interpolatePurples', invert: 'dark'},
|
||||
{name: 'Reds', value: 'interpolateReds', invert: 'dark'},
|
||||
|
||||
// Sequential (Multi-Hue)
|
||||
{name: 'BuGn', value: 'interpolateBuGn', invert: 'dark'},
|
||||
{name: 'BuPu', value: 'interpolateBuPu', invert: 'dark'},
|
||||
{name: 'GnBu', value: 'interpolateGnBu', invert: 'dark'},
|
||||
{name: 'OrRd', value: 'interpolateOrRd', invert: 'dark'},
|
||||
{name: 'PuBuGn', value: 'interpolatePuBuGn', invert: 'dark'},
|
||||
{name: 'PuBu', value: 'interpolatePuBu', invert: 'dark'},
|
||||
{name: 'PuRd', value: 'interpolatePuRd', invert: 'dark'},
|
||||
{name: 'RdPu', value: 'interpolateRdPu', invert: 'dark'},
|
||||
{name: 'YlGnBu', value: 'interpolateYlGnBu', invert: 'dark'},
|
||||
{name: 'YlGn', value: 'interpolateYlGn', invert: 'dark'},
|
||||
{name: 'YlOrBr', value: 'interpolateYlOrBr', invert: 'dark'},
|
||||
{name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'darm'}
|
||||
];
|
||||
|
||||
export class HeatmapCtrl extends MetricsPanelCtrl {
|
||||
static templateUrl = 'module.html';
|
||||
|
||||
opacityScales: any = [];
|
||||
colorModes: any = [];
|
||||
colorSchemes: any = [];
|
||||
selectionActivated: boolean;
|
||||
unitFormats: any;
|
||||
data: any;
|
||||
series: any;
|
||||
timeSrv: any;
|
||||
dataWarning: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope, $injector, private $rootScope, timeSrv) {
|
||||
super($scope, $injector);
|
||||
this.$rootScope = $rootScope;
|
||||
this.timeSrv = timeSrv;
|
||||
this.selectionActivated = false;
|
||||
|
||||
_.defaultsDeep(this.panel, panelDefaults);
|
||||
this.opacityScales = opacityScales;
|
||||
this.colorModes = colorModes;
|
||||
this.colorSchemes = colorSchemes;
|
||||
|
||||
// Bind grafana panel events
|
||||
this.events.on('render', this.onRender.bind(this));
|
||||
this.events.on('data-received', this.onDataReceived.bind(this));
|
||||
this.events.on('data-error', this.onDataError.bind(this));
|
||||
this.events.on('data-snapshot-load', this.onDataReceived.bind(this));
|
||||
this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
|
||||
}
|
||||
|
||||
onInitEditMode() {
|
||||
this.addEditorTab('Axes', axesEditor, 2);
|
||||
this.addEditorTab('Display', heatmapDisplayEditor, 3);
|
||||
this.unitFormats = kbn.getUnitFormats();
|
||||
}
|
||||
|
||||
zoomOut(evt) {
|
||||
this.publishAppEvent('zoom-out', 2);
|
||||
}
|
||||
|
||||
onRender() {
|
||||
if (!this.range) { return; }
|
||||
|
||||
let xBucketSize, yBucketSize, heatmapStats, bucketsData;
|
||||
let logBase = this.panel.yAxis.logBase;
|
||||
|
||||
if (this.panel.dataFormat === 'es_histogram') {
|
||||
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;
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
// Set default Y range if no data
|
||||
if (!heatmapStats.min && !heatmapStats.max) {
|
||||
heatmapStats = {min: -1, max: 1, minLog: 1};
|
||||
yBucketSize = 1;
|
||||
}
|
||||
|
||||
this.data = {
|
||||
buckets: bucketsData,
|
||||
heatmapStats: heatmapStats,
|
||||
xBucketSize: xBucketSize,
|
||||
yBucketSize: yBucketSize
|
||||
};
|
||||
}
|
||||
|
||||
onDataReceived(dataList) {
|
||||
this.series = dataList.map(this.seriesHandler.bind(this));
|
||||
|
||||
this.dataWarning = null;
|
||||
const datapointsCount = _.reduce(this.series, (sum, series) => {
|
||||
return sum + series.datapoints.length;
|
||||
}, 0);
|
||||
|
||||
if (datapointsCount === 0) {
|
||||
this.dataWarning = {
|
||||
title: 'No data points',
|
||||
tip: 'No datapoints returned from data query'
|
||||
};
|
||||
} else {
|
||||
for (let series of this.series) {
|
||||
if (series.isOutsideRange) {
|
||||
this.dataWarning = {
|
||||
title: 'Data points outside time range',
|
||||
tip: 'Can be caused by timezone mismatch or missing time filter in query',
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
onDataError() {
|
||||
this.series = [];
|
||||
this.render();
|
||||
}
|
||||
|
||||
seriesHandler(seriesData) {
|
||||
let series = new TimeSeries({
|
||||
datapoints: seriesData.datapoints,
|
||||
alias: seriesData.target
|
||||
});
|
||||
|
||||
series.flotpairs = series.getFlotPairs(this.panel.nullPointMode);
|
||||
series.minLog = getMinLog(series);
|
||||
|
||||
let datapoints = seriesData.datapoints || [];
|
||||
if (datapoints && datapoints.length > 0) {
|
||||
let last = datapoints[datapoints.length - 1][1];
|
||||
let from = this.range.from;
|
||||
if (last - from < -10000) {
|
||||
series.isOutsideRange = true;
|
||||
}
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
parseSeries(series) {
|
||||
let min = _.min(_.map(series, s => s.stats.min));
|
||||
let minLog = _.min(_.map(series, s => s.minLog));
|
||||
let max = _.max(_.map(series, s => s.stats.max));
|
||||
|
||||
return {
|
||||
max: max,
|
||||
min: min,
|
||||
minLog: minLog
|
||||
};
|
||||
}
|
||||
|
||||
parseHistogramSeries(series) {
|
||||
let bounds = _.map(series, s => Number(s.alias));
|
||||
let min = _.min(bounds);
|
||||
let minLog = _.min(bounds);
|
||||
let max = _.max(bounds);
|
||||
|
||||
return {
|
||||
max: max,
|
||||
min: min,
|
||||
minLog: minLog
|
||||
};
|
||||
}
|
||||
|
||||
link(scope, elem, attrs, ctrl) {
|
||||
rendering(scope, elem, attrs, ctrl);
|
||||
}
|
||||
}
|
526
public/app/plugins/panel/heatmap/heatmap_data_converter.ts
Normal file
526
public/app/plugins/panel/heatmap/heatmap_data_converter.ts
Normal file
@ -0,0 +1,526 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import _ from 'lodash';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
|
||||
let VALUE_INDEX = 0;
|
||||
let TIME_INDEX = 1;
|
||||
|
||||
interface XBucket {
|
||||
x: number;
|
||||
buckets: any;
|
||||
}
|
||||
|
||||
interface YBucket {
|
||||
y: number;
|
||||
values: number[];
|
||||
}
|
||||
|
||||
function elasticHistogramToHeatmap(series) {
|
||||
let seriesBuckets = _.map(series, (s: TimeSeries) => {
|
||||
return convertEsSeriesToHeatmap(s);
|
||||
});
|
||||
let buckets = mergeBuckets(seriesBuckets);
|
||||
return buckets;
|
||||
}
|
||||
|
||||
function convertEsSeriesToHeatmap(series: TimeSeries, saveZeroCounts = false) {
|
||||
let xBuckets: XBucket[] = [];
|
||||
|
||||
_.forEach(series.datapoints, point => {
|
||||
let bound = series.alias;
|
||||
let count = point[VALUE_INDEX];
|
||||
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
|
||||
let values = new Array(Math.round(count));
|
||||
values.fill(Number(bound));
|
||||
|
||||
let valueBuckets = {};
|
||||
valueBuckets[bound] = {
|
||||
y: Number(bound),
|
||||
values: values
|
||||
};
|
||||
|
||||
let xBucket: XBucket = {
|
||||
x: point[TIME_INDEX],
|
||||
buckets: valueBuckets
|
||||
};
|
||||
|
||||
// Don't push buckets with 0 count until saveZeroCounts flag is set
|
||||
if (count !== 0 || (count === 0 && saveZeroCounts)) {
|
||||
xBuckets.push(xBucket);
|
||||
}
|
||||
});
|
||||
|
||||
let heatmap: any = {};
|
||||
_.forEach(xBuckets, (bucket: XBucket) => {
|
||||
heatmap[bucket.x] = bucket;
|
||||
});
|
||||
|
||||
return heatmap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert set of time series into heatmap buckets
|
||||
* @return {Object} Heatmap object:
|
||||
* {
|
||||
* xBucketBound_1: {
|
||||
* x: xBucketBound_1,
|
||||
* buckets: {
|
||||
* yBucketBound_1: {
|
||||
* y: yBucketBound_1,
|
||||
* bounds: {bottom, top}
|
||||
* values: [val_1, val_2, ..., val_K],
|
||||
* points: [[val_Y, val_X, series_name], ..., [...]],
|
||||
* seriesStat: {seriesName_1: val_1, seriesName_2: val_2}
|
||||
* },
|
||||
* ...
|
||||
* yBucketBound_M: {}
|
||||
* },
|
||||
* values: [val_1, val_2, ..., val_K],
|
||||
* points: [
|
||||
* [val_Y, val_X, series_name], (point_1)
|
||||
* ...
|
||||
* [...] (point_K)
|
||||
* ]
|
||||
* },
|
||||
* xBucketBound_2: {},
|
||||
* ...
|
||||
* xBucketBound_N: {}
|
||||
* }
|
||||
*/
|
||||
function convertToHeatMap(series, yBucketSize, xBucketSize, logBase) {
|
||||
let seriesBuckets = _.map(series, s => {
|
||||
return seriesToHeatMap(s, yBucketSize, xBucketSize, logBase);
|
||||
});
|
||||
|
||||
let buckets = mergeBuckets(seriesBuckets);
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert buckets into linear array of "cards" - objects, represented heatmap elements.
|
||||
* @param {Object} buckets
|
||||
* @return {Array} Array of "card" objects
|
||||
*/
|
||||
function convertToCards(buckets) {
|
||||
let cards = [];
|
||||
_.forEach(buckets, xBucket => {
|
||||
_.forEach(xBucket.buckets, (yBucket, key) => {
|
||||
if (yBucket.values.length) {
|
||||
let card = {
|
||||
x: Number(xBucket.x),
|
||||
y: Number(key),
|
||||
yBounds: yBucket.bounds,
|
||||
values: yBucket.values,
|
||||
seriesStat: getSeriesStat(yBucket.points)
|
||||
};
|
||||
|
||||
cards.push(card);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Special method for log scales. When series converted into buckets with log scale,
|
||||
* for simplification, 0 values are converted into 0, not into -Infinity. On the other hand, we mean
|
||||
* that all values less than series minimum, is 0 values, and we create special "minimum" bucket for
|
||||
* that values (actually, there're no values less than minimum, so this bucket is empty).
|
||||
* 8-16| | ** | | * | **|
|
||||
* 4-8| * |* *|* |** *| * |
|
||||
* 2-4| * *| | ***| |* |
|
||||
* 1-2|* | | | | | This bucket contains minimum series value
|
||||
* 0.5-1|____|____|____|____|____| This bucket should be displayed as 0 on graph
|
||||
* 0|____|____|____|____|____| This bucket is for 0 values (should actually be -Infinity)
|
||||
* So we should merge two bottom buckets into one (0-value bucket).
|
||||
*
|
||||
* @param {Object} buckets Heatmap buckets
|
||||
* @param {Number} minValue Minimum series value
|
||||
* @return {Object} Transformed buckets
|
||||
*/
|
||||
function mergeZeroBuckets(buckets, minValue) {
|
||||
_.forEach(buckets, xBucket => {
|
||||
let yBuckets = xBucket.buckets;
|
||||
|
||||
let emptyBucket = {
|
||||
bounds: {bottom: 0, top: 0},
|
||||
values: [],
|
||||
points: []
|
||||
};
|
||||
|
||||
let nullBucket = yBuckets[0] || emptyBucket;
|
||||
let minBucket = yBuckets[minValue] || emptyBucket;
|
||||
|
||||
let newBucket = {
|
||||
y: 0,
|
||||
bounds: {bottom: minValue, top: minBucket.bounds.top || minValue},
|
||||
values: [],
|
||||
points: []
|
||||
};
|
||||
|
||||
if (nullBucket.values) {
|
||||
newBucket.values = nullBucket.values.concat(minBucket.values);
|
||||
}
|
||||
if (nullBucket.points) {
|
||||
newBucket.points = nullBucket.points.concat(minBucket.points);
|
||||
}
|
||||
|
||||
let newYBuckets = {};
|
||||
_.forEach(yBuckets, (bucket, bound) => {
|
||||
bound = Number(bound);
|
||||
if (bound !== 0 && bound !== minValue) {
|
||||
newYBuckets[bound] = bucket;
|
||||
}
|
||||
});
|
||||
newYBuckets[0] = newBucket;
|
||||
xBucket.buckets = newYBuckets;
|
||||
});
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove 0 values from heatmap buckets.
|
||||
*/
|
||||
function removeZeroBuckets(buckets) {
|
||||
_.forEach(buckets, xBucket => {
|
||||
let yBuckets = xBucket.buckets;
|
||||
let newYBuckets = {};
|
||||
_.forEach(yBuckets, (bucket, bound) => {
|
||||
if (bucket.y !== 0) {
|
||||
newYBuckets[bound] = bucket;
|
||||
}
|
||||
});
|
||||
xBucket.buckets = newYBuckets;
|
||||
});
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count values number for each timeseries in given bucket
|
||||
* @param {Array} points Bucket's datapoints with series name ([val, ts, series_name])
|
||||
* @return {Object} seriesStat: {seriesName_1: val_1, seriesName_2: val_2}
|
||||
*/
|
||||
function getSeriesStat(points) {
|
||||
return _.countBy(points, p => p[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert individual series to heatmap buckets
|
||||
*/
|
||||
function seriesToHeatMap(series, yBucketSize, xBucketSize, logBase = 1) {
|
||||
let datapoints = series.datapoints;
|
||||
let seriesName = series.label;
|
||||
let xBuckets = {};
|
||||
|
||||
// Slice series into X axis buckets
|
||||
// | | ** | | * | **|
|
||||
// | * |* *|* |** *| * |
|
||||
// |** *| | ***| |* |
|
||||
// |____|____|____|____|____|_
|
||||
//
|
||||
_.forEach(datapoints, point => {
|
||||
let bucketBound = getBucketBound(point[TIME_INDEX], xBucketSize);
|
||||
pushToXBuckets(xBuckets, point, bucketBound, seriesName);
|
||||
});
|
||||
|
||||
// Slice X axis buckets into Y (value) buckets
|
||||
// | **| |2|,
|
||||
// | * | --\ |1|,
|
||||
// |* | --/ |1|,
|
||||
// |____| |0|
|
||||
//
|
||||
_.forEach(xBuckets, xBucket => {
|
||||
if (logBase !== 1) {
|
||||
xBucket.buckets = convertToLogScaleValueBuckets(xBucket, yBucketSize, logBase);
|
||||
} else {
|
||||
xBucket.buckets = convertToValueBuckets(xBucket, yBucketSize);
|
||||
}
|
||||
});
|
||||
return xBuckets;
|
||||
}
|
||||
|
||||
function pushToXBuckets(buckets, point, bucketNum, seriesName) {
|
||||
let value = point[VALUE_INDEX];
|
||||
if (value === null || value === undefined || isNaN(value)) { return; }
|
||||
|
||||
// Add series name to point for future identification
|
||||
point.push(seriesName);
|
||||
|
||||
if (buckets[bucketNum] && buckets[bucketNum].values) {
|
||||
buckets[bucketNum].values.push(value);
|
||||
buckets[bucketNum].points.push(point);
|
||||
} else {
|
||||
buckets[bucketNum] = {
|
||||
x: bucketNum,
|
||||
values: [value],
|
||||
points: [point]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function pushToYBuckets(buckets, bucketNum, value, point, bounds) {
|
||||
if (buckets[bucketNum]) {
|
||||
buckets[bucketNum].values.push(value);
|
||||
buckets[bucketNum].points.push(point);
|
||||
} else {
|
||||
buckets[bucketNum] = {
|
||||
y: bucketNum,
|
||||
bounds: bounds,
|
||||
values: [value],
|
||||
points: [point]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getValueBucketBound(value, yBucketSize, logBase) {
|
||||
if (logBase === 1) {
|
||||
return getBucketBound(value, yBucketSize);
|
||||
} else {
|
||||
return getLogScaleBucketBound(value, yBucketSize, logBase);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bucket for given value (for linear scale)
|
||||
*/
|
||||
function getBucketBounds(value, bucketSize) {
|
||||
let bottom, top;
|
||||
bottom = Math.floor(value / bucketSize) * bucketSize;
|
||||
top = (Math.floor(value / bucketSize) + 1) * bucketSize;
|
||||
|
||||
return {bottom, top};
|
||||
}
|
||||
|
||||
function getBucketBound(value, bucketSize) {
|
||||
let bounds = getBucketBounds(value, bucketSize);
|
||||
return bounds.bottom;
|
||||
}
|
||||
|
||||
function convertToValueBuckets(xBucket, bucketSize) {
|
||||
let values = xBucket.values;
|
||||
let points = xBucket.points;
|
||||
let buckets = {};
|
||||
_.forEach(values, (val, index) => {
|
||||
let bounds = getBucketBounds(val, bucketSize);
|
||||
let bucketNum = bounds.bottom;
|
||||
pushToYBuckets(buckets, bucketNum, val, points[index], bounds);
|
||||
});
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bucket for given value (for log scales)
|
||||
*/
|
||||
function getLogScaleBucketBounds(value, yBucketSplitFactor, logBase) {
|
||||
let top, bottom;
|
||||
if (value === 0) {
|
||||
return {bottom: 0, top: 0};
|
||||
}
|
||||
|
||||
let value_log = logp(value, logBase);
|
||||
let pow, powTop;
|
||||
if (yBucketSplitFactor === 1 || !yBucketSplitFactor) {
|
||||
pow = Math.floor(value_log);
|
||||
powTop = pow + 1;
|
||||
} else {
|
||||
let additional_bucket_size = 1 / yBucketSplitFactor;
|
||||
let additional_log = value_log - Math.floor(value_log);
|
||||
additional_log = Math.floor(additional_log / additional_bucket_size) * additional_bucket_size;
|
||||
pow = Math.floor(value_log) + additional_log;
|
||||
powTop = pow + additional_bucket_size;
|
||||
}
|
||||
bottom = Math.pow(logBase, pow);
|
||||
top = Math.pow(logBase, powTop);
|
||||
|
||||
return {bottom, top};
|
||||
}
|
||||
|
||||
function getLogScaleBucketBound(value, yBucketSplitFactor, logBase) {
|
||||
let bounds = getLogScaleBucketBounds(value, yBucketSplitFactor, logBase);
|
||||
return bounds.bottom;
|
||||
}
|
||||
|
||||
function convertToLogScaleValueBuckets(xBucket, yBucketSplitFactor, logBase) {
|
||||
let values = xBucket.values;
|
||||
let points = xBucket.points;
|
||||
|
||||
let buckets = {};
|
||||
_.forEach(values, (val, index) => {
|
||||
let bounds = getLogScaleBucketBounds(val, yBucketSplitFactor, logBase);
|
||||
let bucketNum = bounds.bottom;
|
||||
pushToYBuckets(buckets, bucketNum, val, points[index], bounds);
|
||||
});
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge individual buckets for all series into one
|
||||
* @param {Array} seriesBuckets Array of series buckets
|
||||
* @return {Object} Merged buckets.
|
||||
*/
|
||||
function mergeBuckets(seriesBuckets) {
|
||||
let mergedBuckets: any = {};
|
||||
_.forEach(seriesBuckets, (seriesBucket, index) => {
|
||||
if (index === 0) {
|
||||
mergedBuckets = seriesBucket;
|
||||
} else {
|
||||
_.forEach(seriesBucket, (xBucket, xBound) => {
|
||||
if (mergedBuckets[xBound]) {
|
||||
if (xBucket.points) {
|
||||
mergedBuckets[xBound].points = xBucket.points.concat(mergedBuckets[xBound].points);
|
||||
}
|
||||
if (xBucket.values) {
|
||||
mergedBuckets[xBound].values = xBucket.values.concat(mergedBuckets[xBound].values);
|
||||
}
|
||||
|
||||
_.forEach(xBucket.buckets, (yBucket, yBound) => {
|
||||
let bucket = mergedBuckets[xBound].buckets[yBound];
|
||||
if (bucket && bucket.values) {
|
||||
mergedBuckets[xBound].buckets[yBound].values = bucket.values.concat(yBucket.values);
|
||||
|
||||
if (bucket.points) {
|
||||
mergedBuckets[xBound].buckets[yBound].points = bucket.points.concat(yBucket.points);
|
||||
}
|
||||
} else {
|
||||
mergedBuckets[xBound].buckets[yBound] = yBucket;
|
||||
}
|
||||
|
||||
let points = mergedBuckets[xBound].buckets[yBound].points;
|
||||
if (points) {
|
||||
mergedBuckets[xBound].buckets[yBound].seriesStat = getSeriesStat(points);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
mergedBuckets[xBound] = xBucket;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return mergedBuckets;
|
||||
}
|
||||
|
||||
// Get minimum non zero value.
|
||||
function getMinLog(series) {
|
||||
let values = _.compact(_.map(series.datapoints, p => p[0]));
|
||||
return _.min(values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logarithm for custom base
|
||||
* @param value
|
||||
* @param base logarithm base
|
||||
*/
|
||||
function logp(value, base) {
|
||||
return Math.log(value) / Math.log(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate size of Y bucket from given buckets bounds.
|
||||
* @param bounds Array of Y buckets bounds
|
||||
* @param logBase Logarithm base
|
||||
*/
|
||||
function calculateBucketSize(bounds: number[], logBase = 1): number {
|
||||
let bucketSize = Infinity;
|
||||
|
||||
if (bounds.length === 0) {
|
||||
return 0;
|
||||
} else if (bounds.length === 1) {
|
||||
return bounds[0];
|
||||
} else {
|
||||
bounds = _.sortBy(bounds);
|
||||
for (let i = 1; i < bounds.length; i++) {
|
||||
let distance = getDistance(bounds[i], bounds[i - 1], logBase);
|
||||
bucketSize = distance < bucketSize ? distance : bucketSize;
|
||||
}
|
||||
}
|
||||
|
||||
return bucketSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two numbers in given scale (linear or logarithmic).
|
||||
* @param a
|
||||
* @param b
|
||||
* @param logBase
|
||||
*/
|
||||
function getDistance(a: number, b: number, logBase = 1): number {
|
||||
if (logBase === 1) {
|
||||
// Linear distance
|
||||
return Math.abs(b - a);
|
||||
} else {
|
||||
// logarithmic distance
|
||||
let ratio = Math.max(a, b) / Math.min(a, b);
|
||||
return logp(ratio, logBase);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two heatmap data objects
|
||||
* @param objA
|
||||
* @param objB
|
||||
*/
|
||||
function isHeatmapDataEqual(objA: any, objB: any): boolean {
|
||||
let is_eql = !emptyXOR(objA, objB);
|
||||
|
||||
_.forEach(objA, (xBucket: XBucket, x) => {
|
||||
if (objB[x]) {
|
||||
if (emptyXOR(xBucket.buckets, objB[x].buckets)) {
|
||||
is_eql = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
_.forEach(xBucket.buckets, (yBucket: YBucket, y) => {
|
||||
if (objB[x].buckets && objB[x].buckets[y]) {
|
||||
if (objB[x].buckets[y].values) {
|
||||
is_eql = _.isEqual(_.sortBy(yBucket.values), _.sortBy(objB[x].buckets[y].values));
|
||||
if (!is_eql) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
is_eql = false;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
is_eql = false;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!is_eql) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
is_eql = false;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return is_eql;
|
||||
}
|
||||
|
||||
function emptyXOR(foo: any, bar: any): boolean {
|
||||
return (_.isEmpty(foo) || _.isEmpty(bar)) && !(_.isEmpty(foo) && _.isEmpty(bar));
|
||||
}
|
||||
|
||||
export {
|
||||
convertToHeatMap,
|
||||
elasticHistogramToHeatmap,
|
||||
convertToCards,
|
||||
removeZeroBuckets,
|
||||
mergeZeroBuckets,
|
||||
getMinLog,
|
||||
getValueBucketBound,
|
||||
isHeatmapDataEqual,
|
||||
calculateBucketSize
|
||||
};
|
250
public/app/plugins/panel/heatmap/heatmap_tooltip.ts
Normal file
250
public/app/plugins/panel/heatmap/heatmap_tooltip.ts
Normal file
@ -0,0 +1,250 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import d3 from 'd3';
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import {getValueBucketBound} from './heatmap_data_converter';
|
||||
|
||||
let TOOLTIP_PADDING_X = 30;
|
||||
let TOOLTIP_PADDING_Y = 5;
|
||||
let HISTOGRAM_WIDTH = 160;
|
||||
let HISTOGRAM_HEIGHT = 40;
|
||||
|
||||
export class HeatmapTooltip {
|
||||
tooltip: any;
|
||||
scope: any;
|
||||
dashboard: any;
|
||||
panel: any;
|
||||
heatmapPanel: any;
|
||||
mouseOverBucket: boolean;
|
||||
originalFillColor: any;
|
||||
|
||||
constructor(elem, scope) {
|
||||
this.scope = scope;
|
||||
this.dashboard = scope.ctrl.dashboard;
|
||||
this.panel = scope.ctrl.panel;
|
||||
this.heatmapPanel = elem;
|
||||
this.mouseOverBucket = false;
|
||||
this.originalFillColor = null;
|
||||
|
||||
elem.on("mouseover", this.onMouseOver.bind(this));
|
||||
elem.on("mouseleave", this.onMouseLeave.bind(this));
|
||||
}
|
||||
|
||||
onMouseOver(e) {
|
||||
if (!this.panel.tooltip.show || _.isEmpty(this.scope.ctrl.data.buckets)) { return; }
|
||||
|
||||
if (!this.tooltip) {
|
||||
this.add();
|
||||
this.move(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
onMouseMove(e) {
|
||||
if (!this.panel.tooltip.show) { return; }
|
||||
|
||||
this.move(e);
|
||||
}
|
||||
|
||||
add() {
|
||||
this.tooltip = d3.select("body")
|
||||
.append("div")
|
||||
.attr("class", "heatmap-tooltip graph-tooltip grafana-tooltip");
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.tooltip) {
|
||||
this.tooltip.remove();
|
||||
}
|
||||
|
||||
this.tooltip = null;
|
||||
}
|
||||
|
||||
show(pos, data) {
|
||||
if (!this.panel.tooltip.show || !data) { return; }
|
||||
|
||||
let {xBucketIndex, yBucketIndex} = this.getBucketIndexes(pos, data);
|
||||
|
||||
if (!data.buckets[xBucketIndex] || !this.tooltip) {
|
||||
this.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
let boundBottom, boundTop, valuesNumber;
|
||||
let xData = data.buckets[xBucketIndex];
|
||||
let yData = xData.buckets[yBucketIndex];
|
||||
|
||||
let tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss';
|
||||
let time = this.dashboard.formatDate(xData.x, tooltipTimeFormat);
|
||||
let decimals = this.panel.tooltipDecimals || 5;
|
||||
let valueFormatter = this.valueFormatter(decimals);
|
||||
|
||||
let tooltipHtml = `<div class="graph-tooltip-time">${time}</div>
|
||||
<div class="heatmap-histogram"></div>`;
|
||||
|
||||
if (yData) {
|
||||
boundBottom = valueFormatter(yData.bounds.bottom);
|
||||
boundTop = valueFormatter(yData.bounds.top);
|
||||
valuesNumber = yData.values.length;
|
||||
tooltipHtml += `<div>
|
||||
bucket: <b>${boundBottom} - ${boundTop}</b> <br>
|
||||
count: <b>${valuesNumber}</b> <br>
|
||||
</div>`;
|
||||
|
||||
if (this.panel.tooltip.seriesStat && yData.seriesStat) {
|
||||
tooltipHtml = this.addSeriesStat(tooltipHtml, yData.seriesStat);
|
||||
}
|
||||
} else {
|
||||
if (!this.panel.tooltip.showHistogram) {
|
||||
this.destroy();
|
||||
return;
|
||||
}
|
||||
boundBottom = yBucketIndex;
|
||||
boundTop = '';
|
||||
valuesNumber = 0;
|
||||
}
|
||||
|
||||
this.tooltip.html(tooltipHtml);
|
||||
|
||||
if (this.panel.tooltip.showHistogram) {
|
||||
this.addHistogram(xData);
|
||||
}
|
||||
|
||||
this.move(pos);
|
||||
}
|
||||
|
||||
getBucketIndexes(pos, data) {
|
||||
let xBucketIndex, yBucketIndex;
|
||||
|
||||
// if panelRelY is defined another panel wants us to show a tooltip
|
||||
if (pos.panelRelY) {
|
||||
xBucketIndex = getValueBucketBound(pos.x, data.xBucketSize, 1);
|
||||
let y = this.scope.yScale.invert(pos.panelRelY * this.scope.chartHeight);
|
||||
yBucketIndex = getValueBucketBound(y, data.yBucketSize, this.panel.yAxis.logBase);
|
||||
pos = this.getSharedTooltipPos(pos);
|
||||
|
||||
if (!this.tooltip) {
|
||||
// Add shared tooltip for panel
|
||||
this.add();
|
||||
}
|
||||
} else {
|
||||
xBucketIndex = this.getXBucketIndex(pos.offsetX, data);
|
||||
yBucketIndex = this.getYBucketIndex(pos.offsetY, data);
|
||||
}
|
||||
|
||||
return {xBucketIndex, yBucketIndex};
|
||||
}
|
||||
|
||||
getXBucketIndex(offsetX, data) {
|
||||
let x = this.scope.xScale.invert(offsetX - this.scope.yAxisWidth).valueOf();
|
||||
let xBucketIndex = getValueBucketBound(x, data.xBucketSize, 1);
|
||||
return xBucketIndex;
|
||||
}
|
||||
|
||||
getYBucketIndex(offsetY, data) {
|
||||
let y = this.scope.yScale.invert(offsetY - this.scope.chartTop);
|
||||
let yBucketIndex = getValueBucketBound(y, data.yBucketSize, this.panel.yAxis.logBase);
|
||||
return yBucketIndex;
|
||||
}
|
||||
|
||||
getSharedTooltipPos(pos) {
|
||||
// get pageX from position on x axis and pageY from relative position in original panel
|
||||
pos.pageX = this.heatmapPanel.offset().left + this.scope.xScale(pos.x);
|
||||
pos.pageY = this.heatmapPanel.offset().top + this.scope.chartHeight * pos.panelRelY;
|
||||
return pos;
|
||||
}
|
||||
|
||||
addSeriesStat(tooltipHtml, seriesStat) {
|
||||
tooltipHtml += "series: <br>";
|
||||
_.forEach(seriesStat, (values, series) => {
|
||||
tooltipHtml += ` - ${series}: <b>${values}</b><br>`;
|
||||
});
|
||||
|
||||
return tooltipHtml;
|
||||
}
|
||||
|
||||
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 histogramData = _.map(xBucket.buckets, bucket => {
|
||||
return [bucket.y, bucket.values.length];
|
||||
});
|
||||
histogramData = _.filter(histogramData, d => {
|
||||
return d[0] >= min && d[0] <= max;
|
||||
});
|
||||
|
||||
let scale = this.scope.yScale.copy();
|
||||
let histXScale = scale
|
||||
.domain([min, max])
|
||||
.range([0, HISTOGRAM_WIDTH]);
|
||||
|
||||
let barWidth;
|
||||
if (this.panel.yAxis.logBase === 1) {
|
||||
barWidth = Math.floor(HISTOGRAM_WIDTH / (max - min) * yBucketSize * 0.9);
|
||||
} else {
|
||||
barWidth = Math.floor(HISTOGRAM_WIDTH / ticks / yBucketSize * 0.9);
|
||||
}
|
||||
barWidth = Math.max(barWidth, 1);
|
||||
|
||||
let histYScale = d3.scaleLinear()
|
||||
.domain([0, _.max(_.map(histogramData, d => d[1]))])
|
||||
.range([0, HISTOGRAM_HEIGHT]);
|
||||
|
||||
let histogram = this.tooltip.select(".heatmap-histogram")
|
||||
.append("svg")
|
||||
.attr("width", HISTOGRAM_WIDTH)
|
||||
.attr("height", HISTOGRAM_HEIGHT);
|
||||
|
||||
histogram.selectAll(".bar").data(histogramData)
|
||||
.enter().append("rect")
|
||||
.attr("x", d => {
|
||||
return histXScale(d[0]);
|
||||
})
|
||||
.attr("width", barWidth)
|
||||
.attr("y", d => {
|
||||
return HISTOGRAM_HEIGHT - histYScale(d[1]);
|
||||
})
|
||||
.attr("height", d => {
|
||||
return histYScale(d[1]);
|
||||
});
|
||||
}
|
||||
|
||||
move(pos) {
|
||||
if (!this.tooltip) { return; }
|
||||
|
||||
let elem = $(this.tooltip.node())[0];
|
||||
let tooltipWidth = elem.clientWidth;
|
||||
let tooltipHeight = elem.clientHeight;
|
||||
|
||||
let left = pos.pageX + TOOLTIP_PADDING_X;
|
||||
let top = pos.pageY + TOOLTIP_PADDING_Y;
|
||||
|
||||
if (pos.pageX + tooltipWidth + 40 > window.innerWidth) {
|
||||
left = pos.pageX - tooltipWidth - TOOLTIP_PADDING_X;
|
||||
}
|
||||
|
||||
if (pos.pageY - window.pageYOffset + tooltipHeight + 20 > window.innerHeight) {
|
||||
top = pos.pageY - tooltipHeight - TOOLTIP_PADDING_Y;
|
||||
}
|
||||
|
||||
return this.tooltip
|
||||
.style("left", left + "px")
|
||||
.style("top", top + "px");
|
||||
}
|
||||
|
||||
valueFormatter(decimals) {
|
||||
let format = this.panel.yAxis.format;
|
||||
return function(value) {
|
||||
if (_.isInteger(value)) {
|
||||
decimals = 0;
|
||||
}
|
||||
return kbn.valueFormats[format](value, decimals);
|
||||
};
|
||||
}
|
||||
}
|
195
public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg
Normal file
195
public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg
Normal file
@ -0,0 +1,195 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="100px"
|
||||
height="100px"
|
||||
viewBox="0 0 100 100"
|
||||
style="enable-background:new 0 0 100 100;"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="icn-heatmap-panel.svg"
|
||||
inkscape:version="0.92.1 unknown"><metadata
|
||||
id="metadata108"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs106" /><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="2491"
|
||||
inkscape:window-height="1410"
|
||||
id="namedview104"
|
||||
showgrid="false"
|
||||
inkscape:zoom="9.44"
|
||||
inkscape:cx="37.431994"
|
||||
inkscape:cy="46.396264"
|
||||
inkscape:window-x="69"
|
||||
inkscape:window-y="30"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_1" /><rect
|
||||
x="-0.017525015"
|
||||
y="33.438038"
|
||||
style="opacity:0.35714285;fill:#decd87;fill-opacity:1;stroke-width:0.70710677"
|
||||
width="15.8115"
|
||||
height="15.049"
|
||||
id="rect69" /><path
|
||||
style="opacity:0.42857145;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 16.874036,24.263391 v -7.46822 h 7.891949 7.891949 v 7.46822 7.46822 h -7.891949 -7.891949 z"
|
||||
id="path4883"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.79365079;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 33.69883,24.337252 v -7.46822 h 7.891949 7.891949 v 7.46822 7.46822 H 41.590779 33.69883 Z"
|
||||
id="path4885"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.80952382;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 50.523624,24.337251 v -7.46822 h 7.891949 7.89195 v 7.46822 7.46822 h -7.89195 -7.891949 z"
|
||||
id="path4887"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.43650794;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 67.348418,24.167764 v -7.46822 h 7.891949 7.891949 v 7.46822 7.46822 h -7.891949 -7.891949 z"
|
||||
id="path4889"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.24603176;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 84.173218,24.279957 v -7.46822 h 7.891947 7.891956 v 7.46822 7.46822 h -7.891956 -7.891947 z"
|
||||
id="path4891"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.38158725;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 84.226177,40.968612 v -7.46822 h 7.891949 7.891954 v 7.46822 7.468221 h -7.891954 -7.891949 z"
|
||||
id="path4893"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.75396824;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 67.377433,40.884464 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
|
||||
id="path4895"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.94444442;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 50.528693,41.011582 v -7.46822 h 7.891949 7.89195 v 7.46822 7.468221 h -7.89195 -7.891949 z"
|
||||
id="path4897"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.53174606;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 33.679956,41.011587 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
|
||||
id="path4899"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.64285715;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 16.831216,40.956187 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
|
||||
id="path4901"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.58730158;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 0.04924114,57.615687 v -7.46822 H 7.8882241 15.727207 v 7.46822 7.468221 H 7.8882241 0.04924114 Z"
|
||||
id="path4905"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.43650794;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 16.884627,57.648974 v -7.46822 h 7.838984 7.838983 v 7.46822 7.468221 h -7.838983 -7.838984 z"
|
||||
id="path4907"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:1;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 67.390785,57.601163 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
|
||||
id="path4913"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.29365079;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 84.226177,57.657262 v -7.46822 h 7.891947 7.891946 v 7.46822 7.468221 h -7.891946 -7.891947 z"
|
||||
id="path4915"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.73015873;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 84.226177,74.345913 v -7.46822 h 7.891948 7.891955 v 7.46822 7.468221 h -7.891955 -7.891948 z"
|
||||
id="path4917"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.58730158;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 67.380199,74.317863 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
|
||||
id="path4919"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.66666667;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 50.534214,74.360232 v -7.46822 h 7.891949 7.89195 v 7.46822 7.468221 h -7.89195 -7.891949 z"
|
||||
id="path4921"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.84920636;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 33.688232,74.360242 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
|
||||
id="path4923"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.70634921;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 16.842256,74.341769 v -7.46822 h 7.891949 7.891949 v 7.46822 7.468221 h -7.891949 -7.891949 z"
|
||||
id="path4925"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.43650794;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="M -0.00372516,74.325127 V 66.856906 H 7.8882239 15.780174 v 7.468221 7.46822 H 7.8882239 -0.00372516 Z"
|
||||
id="path4927"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.13492061;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="M 0.04924124,91.034564 V 83.566343 H 7.8882241 15.727207 v 7.468221 7.468221 H 7.8882241 0.04924114 Z"
|
||||
id="path4929"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.26190479;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="M 16.88187,91.034561 V 83.56634 h 7.838983 7.838984 v 7.468221 7.468224 h -7.838983 -7.838983 z"
|
||||
id="path4931"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.58730158;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 33.714496,91.034569 v -7.468221 h 7.891949 7.891949 v 7.468221 7.468216 h -7.891949 -7.891949 z"
|
||||
id="path4933"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.30158727;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="M 50.547126,91.034561 V 83.56634 h 7.891949 7.89195 v 7.468221 7.468224 h -7.89195 -7.891949 z"
|
||||
id="path4935"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.15873018;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 67.379756,91.034564 v -7.468221 h 7.891949 7.891949 v 7.468221 7.468221 h -7.891949 -7.891949 z"
|
||||
id="path4937"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.11904764;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 84.212376,91.034568 v -7.468221 h 7.891952 7.89195 v 7.468221 7.468217 h -7.89195 -7.891952 z"
|
||||
id="path4939"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.89682539;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 50.555398,57.68591 v -7.46822 h 7.838983 7.838983 v 7.46822 7.468221 h -7.838983 -7.838983 z"
|
||||
id="path4941"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:1;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 33.720011,57.685908 v -7.46822 h 7.838983 7.838983 v 7.46822 7.468221 h -7.838983 -7.838983 z"
|
||||
id="path4943"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.16666667;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="M 0.04924152,24.249783 V 16.728597 H 7.8882245 15.727207 v 7.521186 7.521186 H 7.8882245 0.04924152 Z"
|
||||
id="path4976"
|
||||
inkscape:connector-curvature="0" /><rect
|
||||
x="16.900255"
|
||||
y="0.10238234"
|
||||
style="opacity:0.43650794;fill:#decd87;fill-opacity:1;stroke-width:0.70710677"
|
||||
width="15.8115"
|
||||
height="15.049"
|
||||
id="rect69-5-7-3" /><rect
|
||||
x="84.304306"
|
||||
y="0.12308588"
|
||||
style="opacity:0.11904764;fill:#decd87;fill-opacity:1;stroke-width:0.70710677"
|
||||
width="15.8115"
|
||||
height="15.049"
|
||||
id="rect69-5-2-2-6" /><path
|
||||
style="opacity:0.3174603;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="M 33.751268,7.6629239 V 0.19470386 h 7.891949 7.891949 V 7.6629239 15.131142 h -7.891949 -7.891949 z"
|
||||
id="path4885-1"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.43650794;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="M 50.602281,7.6629315 V 0.19471149 h 7.891949 7.891951 V 7.6629315 15.13115 H 58.49423 50.602281 Z"
|
||||
id="path4887-2"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.73015873;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="m 67.453295,7.4510673 v -7.4682202 h 7.89195 7.89195 v 7.4682202 7.4682177 h -7.89195 -7.89195 z"
|
||||
id="path4889-9"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
style="opacity:0.15873018;fill:#decd87;fill-opacity:1;stroke-width:0.10593221"
|
||||
d="M -0.02566414,7.5403525 V 0.0191665 H 7.8133188 15.652302 v 7.521186 7.5211835 H 7.8133188 -0.02566414 Z"
|
||||
id="path4976-3"
|
||||
inkscape:connector-curvature="0" /></svg>
|
After Width: | Height: | Size: 10 KiB |
12
public/app/plugins/panel/heatmap/module.html
Normal file
12
public/app/plugins/panel/heatmap/module.html
Normal file
@ -0,0 +1,12 @@
|
||||
<div class="heatmap-wrapper">
|
||||
<div class="heatmap-canvas-wrapper">
|
||||
|
||||
<div class="datapoints-warning" ng-if="ctrl.dataWarning">
|
||||
<span class="small" bs-tooltip="ctrl.dataWarning.tip">{{ctrl.dataWarning.title}}</span>
|
||||
</div>
|
||||
|
||||
<div class="heatmap-panel" ng-dblclick="ctrl.zoomOut()"></div>
|
||||
</div>
|
||||
<!-- <div class="graph-legend-wrapper" ng-if="ctrl.panel.legend.show" heatmap-legend></div> -->
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
7
public/app/plugins/panel/heatmap/module.ts
Normal file
7
public/app/plugins/panel/heatmap/module.ts
Normal file
@ -0,0 +1,7 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import {HeatmapCtrl} from './heatmap_ctrl';
|
||||
|
||||
export {
|
||||
HeatmapCtrl as PanelCtrl
|
||||
};
|
95
public/app/plugins/panel/heatmap/partials/axes_editor.html
Normal file
95
public/app/plugins/panel/heatmap/partials/axes_editor.html
Normal file
@ -0,0 +1,95 @@
|
||||
<div class="editor-row">
|
||||
<div class="section gf-form-group">
|
||||
<h5 class="section-heading">Y Axis</h5>
|
||||
<gf-form-switch class="gf-form" label-class="width-5"
|
||||
label="Show"
|
||||
checked="ctrl.panel.yAxis.show" on-change="ctrl.render()">
|
||||
</gf-form-switch>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-5">Unit</label>
|
||||
<div class="gf-form-dropdown-typeahead max-width-15"
|
||||
ng-model="ctrl.panel.yAxis.format"
|
||||
dropdown-typeahead2="editor.unitFormats"
|
||||
dropdown-typeahead-on-select="editor.setUnitFormat($subItem)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-5">Scale</label>
|
||||
<div class="gf-form-select-wrapper max-width-15">
|
||||
<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-inline">
|
||||
<div class="gf-form max-width-10">
|
||||
<label class="gf-form-label width-5">Y-Min</label>
|
||||
<input type="text" class="gf-form-input" placeholder="auto" empty-to-null ng-model="ctrl.panel.yAxis.min" ng-change="ctrl.render()" ng-model-onblur>
|
||||
</div>
|
||||
<div class="gf-form max-width-10">
|
||||
<label class="gf-form-label width-5">Y-Max</label>
|
||||
<input type="text" class="gf-form-input" 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-10">Decimals</label>
|
||||
<input type="number" class="gf-form-input width-10" 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>
|
||||
</div>
|
||||
<div ng-show="ctrl.panel.yAxis.logBase === 1">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-10">Buckets</label>
|
||||
<input type="number" class="gf-form-input width-10" placeholder="auto" data-placement="right"
|
||||
bs-tooltip="'Number of buckets for Y axis.'"
|
||||
ng-model="ctrl.panel.yBucketNumber" ng-change="ctrl.refresh()" ng-model-onblur>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-10">Bucket Size</label>
|
||||
<input type="number" class="gf-form-input width-10" placeholder="auto" data-placement="right"
|
||||
bs-tooltip="'Size of bucket. Has priority over Buckets option.'"
|
||||
ng-model="ctrl.panel.yBucketSize" ng-change="ctrl.refresh()" ng-model-onblur>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="ctrl.panel.yAxis.logBase !== 1">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-10">Split Buckets</label>
|
||||
<input type="number" class="gf-form-input width-10" placeholder="1" data-placement="right"
|
||||
bs-tooltip="'For log scales only. By default Y values is splitted by integer powers of log base (1, 2, 4, 8, 16, ... for log2). This option allows to split each default bucket into specified number of buckets.'"
|
||||
ng-model="ctrl.panel.yAxis.splitFactor" ng-change="ctrl.refresh()" ng-model-onblur>
|
||||
</div>
|
||||
<gf-form-switch class="gf-form" label-class="width-10"
|
||||
label="Remove zero values"
|
||||
checked="ctrl.panel.yAxis.removeZeroValues" on-change="ctrl.render()">
|
||||
</gf-form-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section gf-form-group">
|
||||
<h5 class="section-heading">X Axis</h5>
|
||||
<gf-form-switch class="gf-form" label-class="width-8"
|
||||
label="Show"
|
||||
checked="ctrl.panel.xAxis.show" on-change="ctrl.render()">
|
||||
</gf-form-switch>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-8">Buckets</label>
|
||||
<input type="number" class="gf-form-input width-8" placeholder="auto" data-placement="right"
|
||||
bs-tooltip="'Number of buckets for X axis.'"
|
||||
ng-model="ctrl.panel.xBucketNumber" ng-change="ctrl.refresh()" ng-model-onblur>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-8">Bucket Size</label>
|
||||
<input type="text" class="gf-form-input width-8" placeholder="auto" data-placement="right"
|
||||
bs-tooltip="'Size of bucket. Number or interval (10s, 5m, 1h, etc). Supported intervals: ms, s, m, h, d, w, M, y. Has priority over Buckets option.'"
|
||||
ng-model="ctrl.panel.xBucketSize" ng-change="ctrl.refresh()" ng-model-onblur>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section gf-form-group">
|
||||
<h5 class="section-heading">Data format</h5>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,87 @@
|
||||
<div class="editor-row">
|
||||
<div class="section gf-form-group">
|
||||
<h5 class="section-heading">Colors</h5>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-7">Mode</label>
|
||||
<div class="gf-form-select-wrapper width-12">
|
||||
<select class="input-small gf-form-input" ng-model="ctrl.panel.color.mode" ng-options="s for s in ctrl.colorModes" ng-change="ctrl.render()"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="ctrl.panel.color.mode === 'opacity'">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-7">Color</label>
|
||||
<span class="gf-form-label">
|
||||
<spectrum-picker ng-model="ctrl.panel.color.cardColor" ng-change="ctrl.render()" ></spectrum-picker>
|
||||
</span>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-7">Scale</label>
|
||||
<div class="gf-form-select-wrapper width-8">
|
||||
<select class="input-small gf-form-input" ng-model="ctrl.panel.color.colorScale" ng-options="s for s in ctrl.opacityScales" ng-change="ctrl.render()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form" ng-if="ctrl.panel.color.colorScale === 'sqrt'">
|
||||
<label class="gf-form-label width-7">Exponent</label>
|
||||
<input type="number" class="gf-form-input width-8" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.color.exponent" ng-change="ctrl.refresh()" ng-model-onblur>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<svg id="heatmap-opacity-legend" width="22.7em" height="2em"></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="ctrl.panel.color.mode === 'spectrum'">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-7">Scheme</label>
|
||||
<div class="gf-form-select-wrapper width-12">
|
||||
<select class="input-small gf-form-input" ng-model="ctrl.panel.color.colorScheme" ng-options="s.value as s.name for s in ctrl.colorSchemes" ng-change="ctrl.render()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<svg id="heatmap-color-legend" width="22.7em" height="2em"></svg>
|
||||
</div>
|
||||
<gf-form-switch class="gf-form" label-class="width-9" label="Fill background" checked="ctrl.panel.color.fillBackground" on-change="ctrl.render()">
|
||||
</gf-form-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section gf-form-group">
|
||||
<h5 class="section-heading">Cards</h5>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-8">Space</label>
|
||||
<input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.cards.cardPadding" ng-change="ctrl.refresh()" ng-model-onblur>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-8">Round</label>
|
||||
<input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.cards.cardRound" ng-change="ctrl.refresh()" ng-model-onblur>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section gf-form-group">
|
||||
<h5 class="section-heading">Tooltip</h5>
|
||||
<gf-form-switch class="gf-form" label-class="width-8"
|
||||
label="Show tooltip"
|
||||
checked="ctrl.panel.tooltip.show" on-change="ctrl.render()">
|
||||
</gf-form-switch>
|
||||
<div ng-if="ctrl.panel.tooltip.show">
|
||||
<gf-form-switch class="gf-form" label-class="width-8"
|
||||
label="Highlight cards"
|
||||
checked="ctrl.panel.highlightCards" on-change="ctrl.render()">
|
||||
</gf-form-switch>
|
||||
<gf-form-switch class="gf-form" label-class="width-8"
|
||||
label="Series stats"
|
||||
checked="ctrl.panel.tooltip.seriesStat" on-change="ctrl.render()">
|
||||
</gf-form-switch>
|
||||
<gf-form-switch class="gf-form" label-class="width-8"
|
||||
label="Histogram"
|
||||
checked="ctrl.panel.tooltip.showHistogram" on-change="ctrl.render()">
|
||||
</gf-form-switch>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-8">Decimals</label>
|
||||
<input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right"
|
||||
bs-tooltip="'Max decimal precision for tooltip.'"
|
||||
ng-model="ctrl.panel.tooltipDecimals" ng-change="ctrl.render()" ng-model-onblur>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
16
public/app/plugins/panel/heatmap/plugin.json
Normal file
16
public/app/plugins/panel/heatmap/plugin.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"type": "panel",
|
||||
"name": "Heatmap",
|
||||
"id": "heatmap",
|
||||
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Project",
|
||||
"url": "https://grafana.com"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/icn-heatmap-panel.svg",
|
||||
"large": "img/icn-heatmap-panel.svg"
|
||||
}
|
||||
}
|
||||
}
|
857
public/app/plugins/panel/heatmap/rendering.ts
Normal file
857
public/app/plugins/panel/heatmap/rendering.ts
Normal file
@ -0,0 +1,857 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import moment from 'moment';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import {appEvents, contextSrv} from 'app/core/core';
|
||||
import {tickStep} from 'app/core/utils/ticks';
|
||||
import d3 from 'd3';
|
||||
import {HeatmapTooltip} from './heatmap_tooltip';
|
||||
import {convertToCards, mergeZeroBuckets, removeZeroBuckets} from './heatmap_data_converter';
|
||||
|
||||
let MIN_CARD_SIZE = 1,
|
||||
CARD_PADDING = 1,
|
||||
CARD_ROUND = 0,
|
||||
DATA_RANGE_WIDING_FACTOR = 1.2,
|
||||
DEFAULT_X_TICK_SIZE_PX = 100,
|
||||
DEFAULT_Y_TICK_SIZE_PX = 50,
|
||||
X_AXIS_TICK_PADDING = 10,
|
||||
Y_AXIS_TICK_PADDING = 5,
|
||||
MIN_SELECTION_WIDTH = 2;
|
||||
|
||||
export default function link(scope, elem, attrs, ctrl) {
|
||||
let data, timeRange, panel, heatmap;
|
||||
|
||||
// $heatmap is JQuery object, but heatmap is D3
|
||||
let $heatmap = elem.find('.heatmap-panel');
|
||||
let tooltip = new HeatmapTooltip($heatmap, scope);
|
||||
|
||||
let width, height,
|
||||
yScale, xScale,
|
||||
chartWidth, chartHeight,
|
||||
chartTop, chartBottom,
|
||||
yAxisWidth, xAxisHeight,
|
||||
cardPadding, cardRound,
|
||||
cardWidth, cardHeight,
|
||||
colorScale, opacityScale,
|
||||
mouseUpHandler;
|
||||
|
||||
let selection = {
|
||||
active: false,
|
||||
x1: -1,
|
||||
x2: -1
|
||||
};
|
||||
|
||||
let padding = {left: 0, right: 0, top: 0, bottom: 0},
|
||||
margin = {left: 25, right: 15, top: 10, bottom: 20},
|
||||
dataRangeWidingFactor = DATA_RANGE_WIDING_FACTOR;
|
||||
|
||||
ctrl.events.on('render', () => {
|
||||
render();
|
||||
ctrl.renderingCompleted();
|
||||
});
|
||||
|
||||
function setElementHeight() {
|
||||
try {
|
||||
var height = ctrl.height || panel.height || ctrl.row.height;
|
||||
if (_.isString(height)) {
|
||||
height = parseInt(height.replace('px', ''), 10);
|
||||
}
|
||||
|
||||
height -= 5; // padding
|
||||
height -= panel.title ? 24 : 9; // subtract panel title bar
|
||||
|
||||
$heatmap.css('height', height + 'px');
|
||||
|
||||
return true;
|
||||
} catch (e) { // IE throws errors sometimes
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getYAxisWidth(elem) {
|
||||
let axis_text = elem.selectAll(".axis-y text").nodes();
|
||||
let max_text_width = _.max(_.map(axis_text, text => {
|
||||
let el = $(text);
|
||||
// Use JQuery outerWidth() to compute full element width
|
||||
return el.outerWidth();
|
||||
}));
|
||||
|
||||
return max_text_width;
|
||||
}
|
||||
|
||||
function getXAxisHeight(elem) {
|
||||
let axis_line = elem.select(".axis-x line");
|
||||
if (!axis_line.empty()) {
|
||||
let axis_line_position = parseFloat(elem.select(".axis-x line").attr("y2"));
|
||||
let canvas_width = parseFloat(elem.attr("height"));
|
||||
return canvas_width - axis_line_position;
|
||||
} else {
|
||||
// Default height
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
|
||||
function addXAxis() {
|
||||
xScale = d3.scaleTime()
|
||||
.domain([timeRange.from, timeRange.to])
|
||||
.range([0, chartWidth]);
|
||||
|
||||
let ticks = chartWidth / DEFAULT_X_TICK_SIZE_PX;
|
||||
let grafanaTimeFormatter = grafanaTimeFormat(ticks, timeRange.from, timeRange.to);
|
||||
|
||||
let xAxis = d3.axisBottom(xScale)
|
||||
.ticks(ticks)
|
||||
.tickFormat(d3.timeFormat(grafanaTimeFormatter))
|
||||
.tickPadding(X_AXIS_TICK_PADDING)
|
||||
.tickSize(chartHeight);
|
||||
|
||||
let posY = margin.top;
|
||||
let posX = yAxisWidth;
|
||||
heatmap.append("g")
|
||||
.attr("class", "axis axis-x")
|
||||
.attr("transform", "translate(" + posX + "," + posY + ")")
|
||||
.call(xAxis);
|
||||
|
||||
// Remove horizontal line in the top of axis labels (called domain in d3)
|
||||
heatmap.select(".axis-x").select(".domain").remove();
|
||||
}
|
||||
|
||||
function addYAxis() {
|
||||
let ticks = Math.ceil(chartHeight / DEFAULT_Y_TICK_SIZE_PX);
|
||||
let tick_interval = 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
|
||||
y_min = panel.yAxis.min !== null ? panel.yAxis.min : y_min;
|
||||
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);
|
||||
ticks = Math.ceil((y_max - y_min) / tick_interval);
|
||||
|
||||
let decimals = panel.yAxis.decimals === null ? getPrecision(tick_interval) : panel.yAxis.decimals;
|
||||
|
||||
// Set default Y min and max if no data
|
||||
if (_.isEmpty(data.buckets)) {
|
||||
y_max = 1;
|
||||
y_min = -1;
|
||||
ticks = 3;
|
||||
decimals = 1;
|
||||
}
|
||||
|
||||
data.yAxis = {
|
||||
min: y_min,
|
||||
max: y_max,
|
||||
ticks: ticks
|
||||
};
|
||||
|
||||
yScale = d3.scaleLinear()
|
||||
.domain([y_min, y_max])
|
||||
.range([chartHeight, 0]);
|
||||
|
||||
let yAxis = d3.axisLeft(yScale)
|
||||
.ticks(ticks)
|
||||
.tickFormat(tickValueFormatter(decimals))
|
||||
.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
|
||||
let posY = margin.top;
|
||||
let 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();
|
||||
}
|
||||
|
||||
// Wide Y values range and anjust to bucket size
|
||||
function wideYAxisRange(min, max, tickInterval) {
|
||||
let y_widing = (max * (dataRangeWidingFactor - 1) - min * (dataRangeWidingFactor - 1)) / 2;
|
||||
let y_min, y_max;
|
||||
|
||||
if (tickInterval === 0) {
|
||||
y_max = max * dataRangeWidingFactor;
|
||||
y_min = min - min * (dataRangeWidingFactor - 1);
|
||||
tickInterval = (y_max - y_min) / 2;
|
||||
} else {
|
||||
y_max = Math.ceil((max + y_widing) / tickInterval) * tickInterval;
|
||||
y_min = Math.floor((min - y_widing) / tickInterval) * tickInterval;
|
||||
}
|
||||
|
||||
// Don't wide axis below 0 if all values are positive
|
||||
if (min >= 0 && y_min < 0) {
|
||||
y_min = 0;
|
||||
}
|
||||
|
||||
return {y_min, y_max};
|
||||
}
|
||||
|
||||
function addLogYAxis() {
|
||||
let log_base = panel.yAxis.logBase;
|
||||
let {y_min, y_max} = adjustLogRange(data.heatmapStats.minLog, data.heatmapStats.max, log_base);
|
||||
|
||||
y_min = panel.yAxis.min !== null ? adjustLogMin(panel.yAxis.min, log_base) : y_min;
|
||||
y_max = panel.yAxis.max !== null ? adjustLogMax(panel.yAxis.max, log_base) : y_max;
|
||||
|
||||
// Set default Y min and max if no data
|
||||
if (_.isEmpty(data.buckets)) {
|
||||
y_max = Math.pow(log_base, 2);
|
||||
y_min = 1;
|
||||
}
|
||||
|
||||
yScale = d3.scaleLog()
|
||||
.base(panel.yAxis.logBase)
|
||||
.domain([y_min, y_max])
|
||||
.range([chartHeight, 0]);
|
||||
|
||||
let domain = yScale.domain();
|
||||
let tick_values = logScaleTickValues(domain, log_base);
|
||||
let decimals = panel.yAxis.decimals;
|
||||
|
||||
data.yAxis = {
|
||||
min: y_min,
|
||||
max: y_max,
|
||||
ticks: tick_values.length
|
||||
};
|
||||
|
||||
let yAxis = d3.axisLeft(yScale)
|
||||
.tickValues(tick_values)
|
||||
.tickFormat(tickValueFormatter(decimals))
|
||||
.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
|
||||
let posY = margin.top;
|
||||
let posX = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
|
||||
heatmap.select(".axis-y").attr("transform", "translate(" + posX + "," + posY + ")");
|
||||
|
||||
// Set first tick as pseudo 0
|
||||
if (y_min < 1) {
|
||||
heatmap.select(".axis-y").select(".tick text").text("0");
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
y_min = data.heatmapStats.minLog;
|
||||
if (data.heatmapStats.minLog > 1 || !data.heatmapStats.minLog) {
|
||||
y_min = 1;
|
||||
} else {
|
||||
y_min = adjustLogMin(data.heatmapStats.minLog, logBase);
|
||||
}
|
||||
|
||||
// Adjust max Y value to log base
|
||||
y_max = adjustLogMax(data.heatmapStats.max, logBase);
|
||||
|
||||
return {y_min, y_max};
|
||||
}
|
||||
|
||||
function adjustLogMax(max, base) {
|
||||
return Math.pow(base, Math.ceil(logp(max, base)));
|
||||
}
|
||||
|
||||
function adjustLogMin(min, base) {
|
||||
return Math.pow(base, Math.floor(logp(min, base)));
|
||||
}
|
||||
|
||||
function logScaleTickValues(domain, base) {
|
||||
let domainMin = domain[0];
|
||||
let domainMax = domain[1];
|
||||
let tickValues = [];
|
||||
|
||||
if (domainMin < 1) {
|
||||
let under_one_ticks = Math.floor(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));
|
||||
for (let i = 0; i <= ticks; i++) {
|
||||
let tick_value = Math.pow(base, i);
|
||||
tickValues.push(tick_value);
|
||||
}
|
||||
|
||||
return tickValues;
|
||||
}
|
||||
|
||||
function tickValueFormatter(decimals) {
|
||||
let format = panel.yAxis.format;
|
||||
return function(value) {
|
||||
return kbn.valueFormats[format](value, decimals);
|
||||
};
|
||||
}
|
||||
|
||||
function fixYAxisTickSize() {
|
||||
heatmap.select(".axis-y")
|
||||
.selectAll(".tick line")
|
||||
.attr("x2", chartWidth);
|
||||
}
|
||||
|
||||
function addAxes() {
|
||||
chartHeight = height - margin.top - margin.bottom;
|
||||
chartTop = margin.top;
|
||||
chartBottom = chartTop + chartHeight;
|
||||
|
||||
if (panel.yAxis.logBase === 1) {
|
||||
addYAxis();
|
||||
} else {
|
||||
addLogYAxis();
|
||||
}
|
||||
|
||||
yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
|
||||
chartWidth = width - yAxisWidth - margin.right;
|
||||
fixYAxisTickSize();
|
||||
|
||||
addXAxis();
|
||||
xAxisHeight = getXAxisHeight(heatmap);
|
||||
|
||||
if (!panel.yAxis.show) {
|
||||
heatmap.select(".axis-y").selectAll("line").style("opacity", 0);
|
||||
}
|
||||
|
||||
if (!panel.xAxis.show) {
|
||||
heatmap.select(".axis-x").selectAll("line").style("opacity", 0);
|
||||
}
|
||||
}
|
||||
|
||||
function addHeatmapCanvas() {
|
||||
let heatmap_elem = $heatmap[0];
|
||||
|
||||
width = Math.floor($heatmap.width()) - padding.right;
|
||||
height = Math.floor($heatmap.height()) - padding.bottom;
|
||||
|
||||
cardPadding = panel.cards.cardPadding !== null ? panel.cards.cardPadding : CARD_PADDING;
|
||||
cardRound = panel.cards.cardRound !== null ? panel.cards.cardRound : CARD_ROUND;
|
||||
|
||||
if (heatmap) {
|
||||
heatmap.remove();
|
||||
}
|
||||
|
||||
heatmap = d3.select(heatmap_elem)
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height);
|
||||
}
|
||||
|
||||
function addHeatmap() {
|
||||
addHeatmapCanvas();
|
||||
addAxes();
|
||||
|
||||
if (panel.yAxis.logBase !== 1) {
|
||||
if (panel.yAxis.removeZeroValues) {
|
||||
data.buckets = removeZeroBuckets(data.buckets);
|
||||
} else {
|
||||
let log_base = panel.yAxis.logBase;
|
||||
let domain = yScale.domain();
|
||||
let tick_values = logScaleTickValues(domain, log_base);
|
||||
data.buckets = mergeZeroBuckets(data.buckets, _.min(tick_values));
|
||||
}
|
||||
}
|
||||
let cardsData = convertToCards(data.buckets);
|
||||
|
||||
let max_value = d3.max(cardsData, card => {
|
||||
return card.values.length;
|
||||
});
|
||||
|
||||
colorScale = getColorScale(max_value);
|
||||
setOpacityScale(max_value);
|
||||
setCardSize();
|
||||
|
||||
if (panel.color.fillBackground && panel.color.mode === 'spectrum') {
|
||||
fillBackground(heatmap, colorScale(0));
|
||||
}
|
||||
|
||||
let cards = heatmap.selectAll(".heatmap-card").data(cardsData);
|
||||
cards.append("title");
|
||||
cards = cards.enter().append("rect")
|
||||
.attr("x", getCardX)
|
||||
.attr("width", getCardWidth)
|
||||
.attr("y", getCardY)
|
||||
.attr("height", getCardHeight)
|
||||
.attr("rx", cardRound)
|
||||
.attr("ry", cardRound)
|
||||
.attr("class", "bordered heatmap-card")
|
||||
.style("fill", getCardColor)
|
||||
.style("stroke", getCardColor)
|
||||
.style("stroke-width", 0)
|
||||
.style("opacity", getCardOpacity);
|
||||
|
||||
let $cards = $heatmap.find(".heatmap-card");
|
||||
$cards.on("mouseenter", (event) => {
|
||||
tooltip.mouseOverBucket = true;
|
||||
highlightCard(event);
|
||||
})
|
||||
.on("mouseleave", (event) => {
|
||||
tooltip.mouseOverBucket = false;
|
||||
resetCardHighLight(event);
|
||||
});
|
||||
}
|
||||
|
||||
function highlightCard(event) {
|
||||
if (panel.highlightCards) {
|
||||
let color = d3.select(event.target).style("fill");
|
||||
let highlightColor = d3.color(color).darker(2);
|
||||
let strokeColor = d3.color(color).brighter(4);
|
||||
let current_card = d3.select(event.target);
|
||||
tooltip.originalFillColor = color;
|
||||
current_card.style("fill", highlightColor)
|
||||
.style("stroke", strokeColor)
|
||||
.style("stroke-width", 1);
|
||||
}
|
||||
}
|
||||
|
||||
function resetCardHighLight(event) {
|
||||
if (panel.highlightCards) {
|
||||
d3.select(event.target).style("fill", tooltip.originalFillColor)
|
||||
.style("stroke", tooltip.originalFillColor)
|
||||
.style("stroke-width", 0);
|
||||
}
|
||||
}
|
||||
|
||||
function getColorScale(maxValue) {
|
||||
let colorScheme = _.find(ctrl.colorSchemes, {value: panel.color.colorScheme});
|
||||
let colorInterpolator = d3[colorScheme.value];
|
||||
let colorScaleInverted = colorScheme.invert === 'always' ||
|
||||
(colorScheme.invert === 'dark' && !contextSrv.user.lightTheme);
|
||||
|
||||
let start = colorScaleInverted ? maxValue : 0;
|
||||
let end = colorScaleInverted ? 0 : maxValue;
|
||||
|
||||
return d3.scaleSequential(colorInterpolator).domain([start, end]);
|
||||
}
|
||||
|
||||
function setOpacityScale(max_value) {
|
||||
if (panel.color.colorScale === 'linear') {
|
||||
opacityScale = d3.scaleLinear()
|
||||
.domain([0, max_value])
|
||||
.range([0, 1]);
|
||||
} else if (panel.color.colorScale === 'sqrt') {
|
||||
opacityScale = d3.scalePow().exponent(panel.color.exponent)
|
||||
.domain([0, max_value])
|
||||
.range([0, 1]);
|
||||
}
|
||||
}
|
||||
|
||||
function setCardSize() {
|
||||
let xGridSize = Math.floor(xScale(data.xBucketSize) - xScale(0));
|
||||
let yGridSize = Math.floor(yScale(yScale.invert(0) - data.yBucketSize));
|
||||
|
||||
if (panel.yAxis.logBase !== 1) {
|
||||
let base = panel.yAxis.logBase;
|
||||
let splitFactor = data.yBucketSize || 1;
|
||||
yGridSize = Math.floor((yScale(1) - yScale(base)) / splitFactor);
|
||||
}
|
||||
|
||||
cardWidth = xGridSize - cardPadding * 2;
|
||||
cardHeight = yGridSize ? yGridSize - cardPadding * 2 : 0;
|
||||
}
|
||||
|
||||
function getCardX(d) {
|
||||
let x;
|
||||
if (xScale(d.x) < 0) {
|
||||
// Cut card left to prevent overlay
|
||||
x = yAxisWidth + cardPadding;
|
||||
} else {
|
||||
x = xScale(d.x) + yAxisWidth + cardPadding;
|
||||
}
|
||||
|
||||
return x;
|
||||
}
|
||||
|
||||
function getCardWidth(d) {
|
||||
let w;
|
||||
if (xScale(d.x) < 0) {
|
||||
// Cut card left to prevent overlay
|
||||
let cutted_width = xScale(d.x) + cardWidth;
|
||||
w = cutted_width > 0 ? cutted_width : 0;
|
||||
} else if (xScale(d.x) + cardWidth > chartWidth) {
|
||||
// Cut card right to prevent overlay
|
||||
w = chartWidth - xScale(d.x) - cardPadding;
|
||||
} else {
|
||||
w = cardWidth;
|
||||
}
|
||||
|
||||
// Card width should be MIN_CARD_SIZE at least
|
||||
w = Math.max(w, MIN_CARD_SIZE);
|
||||
return w;
|
||||
}
|
||||
|
||||
function getCardY(d) {
|
||||
let y = yScale(d.y) + chartTop - cardHeight - cardPadding;
|
||||
if (panel.yAxis.logBase !== 1 && d.y === 0) {
|
||||
y = chartBottom - cardHeight - cardPadding;
|
||||
} else {
|
||||
if (y < chartTop) {
|
||||
y = chartTop;
|
||||
}
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
|
||||
function getCardHeight(d) {
|
||||
let y = yScale(d.y) + chartTop - cardHeight - cardPadding;
|
||||
let h = cardHeight;
|
||||
|
||||
if (panel.yAxis.logBase !== 1 && d.y === 0) {
|
||||
return cardHeight;
|
||||
}
|
||||
|
||||
// Cut card height to prevent overlay
|
||||
if (y < chartTop) {
|
||||
h = yScale(d.y) - cardPadding;
|
||||
} else if (yScale(d.y) > chartBottom) {
|
||||
h = chartBottom - y;
|
||||
} else if (y + cardHeight > chartBottom) {
|
||||
h = chartBottom - y;
|
||||
}
|
||||
|
||||
// Height can't be more than chart height
|
||||
h = Math.min(h, chartHeight);
|
||||
// Card height should be MIN_CARD_SIZE at least
|
||||
h = Math.max(h, MIN_CARD_SIZE);
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
function getCardColor(d) {
|
||||
if (panel.color.mode === 'opacity') {
|
||||
return panel.color.cardColor;
|
||||
} else {
|
||||
return colorScale(d.values.length);
|
||||
}
|
||||
}
|
||||
|
||||
function getCardOpacity(d) {
|
||||
if (panel.color.mode === 'opacity') {
|
||||
return opacityScale(d.values.length);
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function fillBackground(heatmap, color) {
|
||||
heatmap.insert("rect", "g")
|
||||
.attr("x", yAxisWidth)
|
||||
.attr("y", margin.top)
|
||||
.attr("width", chartWidth)
|
||||
.attr("height", chartHeight)
|
||||
.attr("fill", color);
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// Selection and crosshair //
|
||||
/////////////////////////////
|
||||
|
||||
// Shared crosshair and tooltip
|
||||
appEvents.on('graph-hover', event => {
|
||||
drawSharedCrosshair(event.pos);
|
||||
|
||||
// Show shared tooltip
|
||||
if (ctrl.dashboard.graphTooltip === 2) {
|
||||
tooltip.show(event.pos, data);
|
||||
}
|
||||
});
|
||||
|
||||
appEvents.on('graph-hover-clear', () => {
|
||||
clearCrosshair();
|
||||
tooltip.destroy();
|
||||
});
|
||||
|
||||
function onMouseDown(event) {
|
||||
selection.active = true;
|
||||
selection.x1 = event.offsetX;
|
||||
|
||||
mouseUpHandler = function() {
|
||||
onMouseUp();
|
||||
};
|
||||
$(document).one("mouseup", mouseUpHandler);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
$(document).unbind("mouseup", mouseUpHandler);
|
||||
mouseUpHandler = null;
|
||||
selection.active = false;
|
||||
|
||||
let selectionRange = Math.abs(selection.x2 - selection.x1);
|
||||
if (selection.x2 >= 0 && selectionRange > MIN_SELECTION_WIDTH) {
|
||||
let timeFrom = xScale.invert(Math.min(selection.x1, selection.x2) - yAxisWidth);
|
||||
let timeTo = xScale.invert(Math.max(selection.x1, selection.x2) - yAxisWidth);
|
||||
|
||||
ctrl.timeSrv.setTime({
|
||||
from: moment.utc(timeFrom),
|
||||
to: moment.utc(timeTo)
|
||||
});
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
appEvents.emit('graph-hover-clear');
|
||||
clearCrosshair();
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
if (!heatmap) { return; }
|
||||
|
||||
if (selection.active) {
|
||||
// Clear crosshair and tooltip
|
||||
clearCrosshair();
|
||||
tooltip.destroy();
|
||||
|
||||
selection.x2 = limitSelection(event.offsetX);
|
||||
drawSelection(selection.x1, selection.x2);
|
||||
} else {
|
||||
emitGraphHoverEvet(event);
|
||||
drawCrosshair(event.offsetX);
|
||||
tooltip.show(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
function emitGraphHoverEvet(event) {
|
||||
let x = xScale.invert(event.offsetX - yAxisWidth).valueOf();
|
||||
let y = yScale.invert(event.offsetY);
|
||||
let pos = {
|
||||
pageX: event.pageX,
|
||||
pageY: event.pageY,
|
||||
x: x, x1: x,
|
||||
y: y, y1: y,
|
||||
panelRelY: null
|
||||
};
|
||||
|
||||
// Set minimum offset to prevent showing legend from another panel
|
||||
pos.panelRelY = Math.max(event.offsetY / height, 0.001);
|
||||
|
||||
// broadcast to other graph panels that we are hovering
|
||||
appEvents.emit('graph-hover', {pos: pos, panel: panel});
|
||||
}
|
||||
|
||||
function limitSelection(x2) {
|
||||
x2 = Math.max(x2, yAxisWidth);
|
||||
x2 = Math.min(x2, chartWidth + yAxisWidth);
|
||||
return x2;
|
||||
}
|
||||
|
||||
function drawSelection(posX1, posX2) {
|
||||
if (heatmap) {
|
||||
heatmap.selectAll(".heatmap-selection").remove();
|
||||
let selectionX = Math.min(posX1, posX2);
|
||||
let selectionWidth = Math.abs(posX1 - posX2);
|
||||
|
||||
if (selectionWidth > MIN_SELECTION_WIDTH) {
|
||||
heatmap.append("rect")
|
||||
.attr("class", "heatmap-selection")
|
||||
.attr("x", selectionX)
|
||||
.attr("width", selectionWidth)
|
||||
.attr("y", chartTop)
|
||||
.attr("height", chartHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selection.x1 = -1;
|
||||
selection.x2 = -1;
|
||||
|
||||
if (heatmap) {
|
||||
heatmap.selectAll(".heatmap-selection").remove();
|
||||
}
|
||||
}
|
||||
|
||||
function drawCrosshair(position) {
|
||||
if (heatmap) {
|
||||
heatmap.selectAll(".heatmap-crosshair").remove();
|
||||
|
||||
let posX = position;
|
||||
posX = Math.max(posX, yAxisWidth);
|
||||
posX = Math.min(posX, chartWidth + yAxisWidth);
|
||||
|
||||
heatmap.append("g")
|
||||
.attr("class", "heatmap-crosshair")
|
||||
.attr("transform", "translate(" + posX + ",0)")
|
||||
.append("line")
|
||||
.attr("x1", 1)
|
||||
.attr("y1", chartTop)
|
||||
.attr("x2", 1)
|
||||
.attr("y2", chartBottom)
|
||||
.attr("stroke-width", 1);
|
||||
}
|
||||
}
|
||||
|
||||
function drawSharedCrosshair(pos) {
|
||||
if (heatmap && ctrl.dashboard.graphTooltip !== 0) {
|
||||
let posX = xScale(pos.x) + yAxisWidth;
|
||||
drawCrosshair(posX);
|
||||
}
|
||||
}
|
||||
|
||||
function clearCrosshair() {
|
||||
if (heatmap) {
|
||||
heatmap.selectAll(".heatmap-crosshair").remove();
|
||||
}
|
||||
}
|
||||
|
||||
function drawColorLegend() {
|
||||
d3.select("#heatmap-color-legend").selectAll("rect").remove();
|
||||
|
||||
let legend = d3.select("#heatmap-color-legend");
|
||||
let legendWidth = Math.floor($(d3.select("#heatmap-color-legend").node()).outerWidth());
|
||||
let legendHeight = d3.select("#heatmap-color-legend").attr("height");
|
||||
|
||||
let legendColorScale = getColorScale(legendWidth);
|
||||
|
||||
let rangeStep = 2;
|
||||
let valuesRange = d3.range(0, legendWidth, rangeStep);
|
||||
var legendRects = legend.selectAll(".heatmap-color-legend-rect").data(valuesRange);
|
||||
|
||||
legendRects.enter().append("rect")
|
||||
.attr("x", d => d)
|
||||
.attr("y", 0)
|
||||
.attr("width", rangeStep + 1) // Overlap rectangles to prevent gaps
|
||||
.attr("height", legendHeight)
|
||||
.attr("stroke-width", 0)
|
||||
.attr("fill", d => {
|
||||
return legendColorScale(d);
|
||||
});
|
||||
}
|
||||
|
||||
function drawOpacityLegend() {
|
||||
d3.select("#heatmap-opacity-legend").selectAll("rect").remove();
|
||||
|
||||
let legend = d3.select("#heatmap-opacity-legend");
|
||||
let legendWidth = Math.floor($(d3.select("#heatmap-opacity-legend").node()).outerWidth());
|
||||
let legendHeight = d3.select("#heatmap-opacity-legend").attr("height");
|
||||
|
||||
let legendOpacityScale;
|
||||
if (panel.color.colorScale === 'linear') {
|
||||
legendOpacityScale = d3.scaleLinear()
|
||||
.domain([0, legendWidth])
|
||||
.range([0, 1]);
|
||||
} else if (panel.color.colorScale === 'sqrt') {
|
||||
legendOpacityScale = d3.scalePow().exponent(panel.color.exponent)
|
||||
.domain([0, legendWidth])
|
||||
.range([0, 1]);
|
||||
}
|
||||
|
||||
let rangeStep = 1;
|
||||
let valuesRange = d3.range(0, legendWidth, rangeStep);
|
||||
var legendRects = legend.selectAll(".heatmap-opacity-legend-rect").data(valuesRange);
|
||||
|
||||
legendRects.enter().append("rect")
|
||||
.attr("x", d => d)
|
||||
.attr("y", 0)
|
||||
.attr("width", rangeStep)
|
||||
.attr("height", legendHeight)
|
||||
.attr("stroke-width", 0)
|
||||
.attr("fill", panel.color.cardColor)
|
||||
.style("opacity", d => {
|
||||
return legendOpacityScale(d);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
data = ctrl.data;
|
||||
panel = ctrl.panel;
|
||||
timeRange = ctrl.range;
|
||||
|
||||
if (setElementHeight()) {
|
||||
|
||||
if (data) {
|
||||
// Draw default axes and return if no data
|
||||
if (_.isEmpty(data.buckets)) {
|
||||
addHeatmapCanvas();
|
||||
addAxes();
|
||||
return;
|
||||
}
|
||||
|
||||
addHeatmap();
|
||||
scope.yScale = yScale;
|
||||
scope.xScale = xScale;
|
||||
scope.yAxisWidth = yAxisWidth;
|
||||
scope.xAxisHeight = xAxisHeight;
|
||||
scope.chartHeight = chartHeight;
|
||||
scope.chartWidth = chartWidth;
|
||||
scope.chartTop = chartTop;
|
||||
|
||||
// Register selection listeners
|
||||
$heatmap.on("mousedown", onMouseDown);
|
||||
$heatmap.on("mousemove", onMouseMove);
|
||||
$heatmap.on("mouseleave", onMouseLeave);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw only if color editor is opened
|
||||
if (!d3.select("#heatmap-color-legend").empty()) {
|
||||
drawColorLegend();
|
||||
}
|
||||
if (!d3.select("#heatmap-opacity-legend").empty()) {
|
||||
drawOpacityLegend();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function getTicksPrecision(values) {
|
||||
let precisions = _.map(values, getPrecision);
|
||||
return _.max(precisions);
|
||||
}
|
76
public/app/plugins/panel/heatmap/specs/heatmap_ctrl_specs.ts
Normal file
76
public/app/plugins/panel/heatmap/specs/heatmap_ctrl_specs.ts
Normal file
@ -0,0 +1,76 @@
|
||||
///<reference path="../../../../headers/common.d.ts" />
|
||||
|
||||
import {describe, beforeEach, it, sinon, expect, angularMocks} from '../../../../../test/lib/common';
|
||||
|
||||
import angular from 'angular';
|
||||
import moment from 'moment';
|
||||
import {HeatmapCtrl} from '../heatmap_ctrl';
|
||||
import helpers from '../../../../../test/specs/helpers';
|
||||
|
||||
describe('HeatmapCtrl', function() {
|
||||
var ctx = new helpers.ControllerTestContext();
|
||||
|
||||
beforeEach(angularMocks.module('grafana.services'));
|
||||
beforeEach(angularMocks.module('grafana.controllers'));
|
||||
beforeEach(angularMocks.module(function($compileProvider) {
|
||||
$compileProvider.preAssignBindingsEnabled(true);
|
||||
}));
|
||||
|
||||
beforeEach(ctx.providePhase());
|
||||
beforeEach(ctx.createPanelController(HeatmapCtrl));
|
||||
beforeEach(() => {
|
||||
ctx.ctrl.annotationsPromise = Promise.resolve({});
|
||||
ctx.ctrl.updateTimeRange();
|
||||
});
|
||||
|
||||
describe('when time series are outside range', function() {
|
||||
|
||||
beforeEach(function() {
|
||||
var data = [
|
||||
{target: 'test.cpu1', datapoints: [[45, 1234567890], [60, 1234567899]]},
|
||||
];
|
||||
|
||||
ctx.ctrl.range = {from: moment().valueOf(), to: moment().valueOf()};
|
||||
ctx.ctrl.onDataReceived(data);
|
||||
});
|
||||
|
||||
it('should set datapointsOutside', function() {
|
||||
expect(ctx.ctrl.dataWarning.title).to.be('Data points outside time range');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when time series are inside range', function() {
|
||||
beforeEach(function() {
|
||||
var range = {
|
||||
from: moment().subtract(1, 'days').valueOf(),
|
||||
to: moment().valueOf()
|
||||
};
|
||||
|
||||
var data = [
|
||||
{target: 'test.cpu1', datapoints: [[45, range.from + 1000], [60, range.from + 10000]]},
|
||||
];
|
||||
|
||||
ctx.ctrl.range = range;
|
||||
ctx.ctrl.onDataReceived(data);
|
||||
});
|
||||
|
||||
it('should set datapointsOutside', function() {
|
||||
expect(ctx.ctrl.dataWarning).to.be(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('datapointsCount given 2 series', function() {
|
||||
beforeEach(function() {
|
||||
var data = [
|
||||
{target: 'test.cpu1', datapoints: []},
|
||||
{target: 'test.cpu2', datapoints: []},
|
||||
];
|
||||
ctx.ctrl.onDataReceived(data);
|
||||
});
|
||||
|
||||
it('should set datapointsCount warning', function() {
|
||||
expect(ctx.ctrl.dataWarning.title).to.be('No data points');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@ -0,0 +1,253 @@
|
||||
///<reference path="../../../../headers/common.d.ts" />
|
||||
|
||||
import _ from 'lodash';
|
||||
import { describe, beforeEach, it, sinon, expect, angularMocks } from '../../../../../test/lib/common';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
import { convertToHeatMap, elasticHistogramToHeatmap, calculateBucketSize, isHeatmapDataEqual } from '../heatmap_data_converter';
|
||||
|
||||
describe('isHeatmapDataEqual', () => {
|
||||
let ctx: any = {};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.heatmapA = {
|
||||
'1422774000000': {
|
||||
x: 1422774000000,
|
||||
buckets: {
|
||||
'1': { y: 1, values: [1, 1.5] },
|
||||
'2': { y: 2, values: [1] }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ctx.heatmapB = {
|
||||
'1422774000000': {
|
||||
x: 1422774000000,
|
||||
buckets: {
|
||||
'1': { y: 1, values: [1.5, 1] },
|
||||
'2': { y: 2, values: [1] }
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
it('should proper compare objects', () => {
|
||||
let heatmapC = _.cloneDeep(ctx.heatmapA);
|
||||
heatmapC['1422774000000'].buckets['1'].values = [1, 1.5];
|
||||
|
||||
let heatmapD = _.cloneDeep(ctx.heatmapA);
|
||||
heatmapD['1422774000000'].buckets['1'].values = [1.5, 1, 1.6];
|
||||
|
||||
let heatmapE = _.cloneDeep(ctx.heatmapA);
|
||||
heatmapE['1422774000000'].buckets['1'].values = [1, 1.6];
|
||||
|
||||
let empty = {};
|
||||
let emptyValues = _.cloneDeep(ctx.heatmapA);
|
||||
emptyValues['1422774000000'].buckets['1'].values = [];
|
||||
|
||||
expect(isHeatmapDataEqual(ctx.heatmapA, ctx.heatmapB)).to.be(true);
|
||||
expect(isHeatmapDataEqual(ctx.heatmapB, ctx.heatmapA)).to.be(true);
|
||||
|
||||
expect(isHeatmapDataEqual(ctx.heatmapA, heatmapC)).to.be(true);
|
||||
expect(isHeatmapDataEqual(heatmapC, ctx.heatmapA)).to.be(true);
|
||||
|
||||
expect(isHeatmapDataEqual(ctx.heatmapA, heatmapD)).to.be(false);
|
||||
expect(isHeatmapDataEqual(heatmapD, ctx.heatmapA)).to.be(false);
|
||||
|
||||
expect(isHeatmapDataEqual(ctx.heatmapA, heatmapE)).to.be(false);
|
||||
expect(isHeatmapDataEqual(heatmapE, ctx.heatmapA)).to.be(false);
|
||||
|
||||
expect(isHeatmapDataEqual(empty, ctx.heatmapA)).to.be(false);
|
||||
expect(isHeatmapDataEqual(ctx.heatmapA, empty)).to.be(false);
|
||||
|
||||
expect(isHeatmapDataEqual(emptyValues, ctx.heatmapA)).to.be(false);
|
||||
expect(isHeatmapDataEqual(ctx.heatmapA, emptyValues)).to.be(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateBucketSize', () => {
|
||||
let ctx: any = {};
|
||||
|
||||
describe('when logBase is 1 (linear scale)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.logBase = 1;
|
||||
ctx.bounds_set = [
|
||||
{ bounds: [], size: 0 },
|
||||
{ bounds: [0], size: 0 },
|
||||
{ bounds: [4], size: 4 },
|
||||
{ bounds: [0, 1, 2, 3, 4], size: 1 },
|
||||
{ bounds: [0, 1, 3, 5, 7], size: 1 },
|
||||
{ bounds: [0, 3, 7, 9, 15], size: 2 },
|
||||
{ bounds: [0, 7, 3, 15, 9], size: 2 },
|
||||
{ bounds: [0, 5, 10, 15, 50], size: 5 }
|
||||
];
|
||||
});
|
||||
|
||||
it('should properly calculate bucket size', () => {
|
||||
_.each(ctx.bounds_set, (b) => {
|
||||
let bucketSize = calculateBucketSize(b.bounds, ctx.logBase);
|
||||
expect(bucketSize).to.be(b.size);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when logBase is 2', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.logBase = 2;
|
||||
ctx.bounds_set = [
|
||||
{ bounds: [], size: 0 },
|
||||
{ bounds: [0], size: 0 },
|
||||
{ bounds: [4], size: 4 },
|
||||
{ bounds: [1, 2, 4, 8], size: 1 },
|
||||
{ bounds: [1, Math.SQRT2, 2, 8, 16], size: 0.5 }
|
||||
];
|
||||
});
|
||||
|
||||
it('should properly calculate bucket size', () => {
|
||||
_.each(ctx.bounds_set, (b) => {
|
||||
let bucketSize = calculateBucketSize(b.bounds, ctx.logBase);
|
||||
expect(isEqual(bucketSize, b.size)).to.be(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapDataConverter', () => {
|
||||
let ctx: any = {};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.series = [];
|
||||
ctx.series.push(new TimeSeries({
|
||||
datapoints: [[1, 1422774000000], [2, 1422774060000]],
|
||||
alias: 'series1'
|
||||
}));
|
||||
ctx.series.push(new TimeSeries({
|
||||
datapoints: [[2, 1422774000000], [3, 1422774060000]],
|
||||
alias: 'series2'
|
||||
}));
|
||||
|
||||
ctx.xBucketSize = 60000; // 60s
|
||||
ctx.yBucketSize = 1;
|
||||
ctx.logBase = 1;
|
||||
});
|
||||
|
||||
describe('when logBase is 1 (linear scale)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.logBase = 1;
|
||||
});
|
||||
|
||||
it('should build proper heatmap data', () => {
|
||||
let expectedHeatmap = {
|
||||
'1422774000000': {
|
||||
x: 1422774000000,
|
||||
buckets: {
|
||||
'1': { y: 1, values: [1] },
|
||||
'2': { y: 2, values: [2] }
|
||||
}
|
||||
},
|
||||
'1422774060000': {
|
||||
x: 1422774060000,
|
||||
buckets: {
|
||||
'2': { y: 2, values: [2] },
|
||||
'3': { y: 3, values: [3] }
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let heatmap = convertToHeatMap(ctx.series, ctx.yBucketSize, ctx.xBucketSize, ctx.logBase);
|
||||
expect(isHeatmapDataEqual(heatmap, expectedHeatmap)).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when logBase is 2', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.logBase = 2;
|
||||
});
|
||||
|
||||
it('should build proper heatmap data', () => {
|
||||
let expectedHeatmap = {
|
||||
'1422774000000': {
|
||||
x: 1422774000000,
|
||||
buckets: {
|
||||
'1': { y: 1, values: [1] },
|
||||
'2': { y: 2, values: [2] }
|
||||
}
|
||||
},
|
||||
'1422774060000': {
|
||||
x: 1422774060000,
|
||||
buckets: {
|
||||
'2': { y: 2, values: [2, 3] }
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let heatmap = convertToHeatMap(ctx.series, ctx.yBucketSize, ctx.xBucketSize, ctx.logBase);
|
||||
expect(isHeatmapDataEqual(heatmap, expectedHeatmap)).to.be(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ES Histogram converter', () => {
|
||||
let ctx: any = {};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.series = [];
|
||||
ctx.series.push(new TimeSeries({
|
||||
datapoints: [[1, 1422774000000], [0, 1422774060000]],
|
||||
alias: '1', label: '1'
|
||||
}));
|
||||
ctx.series.push(new TimeSeries({
|
||||
datapoints: [[1, 1422774000000], [3, 1422774060000]],
|
||||
alias: '2', label: '2'
|
||||
}));
|
||||
ctx.series.push(new TimeSeries({
|
||||
datapoints: [[0, 1422774000000], [1, 1422774060000]],
|
||||
alias: '3', label: '3'
|
||||
}));
|
||||
});
|
||||
|
||||
describe('when converting ES histogram', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
});
|
||||
|
||||
it('should build proper heatmap data', () => {
|
||||
let expectedHeatmap = {
|
||||
'1422774000000': {
|
||||
x: 1422774000000,
|
||||
buckets: {
|
||||
'1': { y: 1, values: [1] },
|
||||
'2': { y: 2, values: [2] }
|
||||
}
|
||||
},
|
||||
'1422774060000': {
|
||||
x: 1422774060000,
|
||||
buckets: {
|
||||
'2': { y: 2, values: [2, 2, 2] },
|
||||
'3': { y: 3, values: [3] }
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let heatmap = elasticHistogramToHeatmap(ctx.series);
|
||||
expect(isHeatmapDataEqual(heatmap, expectedHeatmap)).to.be(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Compare two numbers with given precision. Suitable for compare float numbers after conversions with precision loss.
|
||||
* @param a
|
||||
* @param b
|
||||
* @param precision
|
||||
*/
|
||||
function isEqual(a: number, b: number, precision = 0.000001): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
} else {
|
||||
return Math.abs(1 - a / b) <= precision;
|
||||
}
|
||||
}
|
267
public/app/plugins/panel/heatmap/specs/renderer_specs.ts
Normal file
267
public/app/plugins/panel/heatmap/specs/renderer_specs.ts
Normal file
@ -0,0 +1,267 @@
|
||||
///<reference path="../../../../headers/common.d.ts" />
|
||||
|
||||
import { describe, beforeEach, it, sinon, expect, angularMocks } from '../../../../../test/lib/common';
|
||||
|
||||
import '../module';
|
||||
import angular from 'angular';
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import helpers from 'test/specs/helpers';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
import moment from 'moment';
|
||||
import { Emitter } from 'app/core/core';
|
||||
import rendering from '../rendering';
|
||||
import { convertToHeatMap } from '../heatmap_data_converter';
|
||||
// import d3 from 'd3';
|
||||
|
||||
describe('grafanaHeatmap', function () {
|
||||
|
||||
beforeEach(angularMocks.module('grafana.core'));
|
||||
|
||||
function heatmapScenario(desc, func, elementWidth = 500) {
|
||||
describe(desc, function () {
|
||||
var ctx: any = {};
|
||||
|
||||
ctx.setup = function (setupFunc) {
|
||||
|
||||
beforeEach(angularMocks.module(function ($provide) {
|
||||
$provide.value("timeSrv", new helpers.TimeSrvStub());
|
||||
}));
|
||||
|
||||
beforeEach(angularMocks.inject(function ($rootScope, $compile) {
|
||||
var ctrl: any = {
|
||||
colorSchemes: [
|
||||
{name: 'Oranges', value: 'interpolateOranges', invert: 'dark'},
|
||||
{name: 'Reds', value: 'interpolateReds', invert: 'dark'},
|
||||
],
|
||||
events: new Emitter(),
|
||||
height: 200,
|
||||
panel: {
|
||||
heatmap: {
|
||||
},
|
||||
cards: {
|
||||
cardPadding: null,
|
||||
cardRound: null
|
||||
},
|
||||
color: {
|
||||
mode: 'spectrum',
|
||||
cardColor: '#b4ff00',
|
||||
colorScale: 'linear',
|
||||
exponent: 0.5,
|
||||
colorScheme: 'interpolateOranges',
|
||||
fillBackground: false
|
||||
},
|
||||
xBucketSize: 1000,
|
||||
xBucketNumber: null,
|
||||
yBucketSize: 1,
|
||||
yBucketNumber: null,
|
||||
xAxis: {
|
||||
show: true
|
||||
},
|
||||
yAxis: {
|
||||
show: true,
|
||||
format: 'short',
|
||||
decimals: null,
|
||||
logBase: 1,
|
||||
splitFactor: null,
|
||||
min: null,
|
||||
max: null,
|
||||
removeZeroValues: false
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
seriesStat: false,
|
||||
showHistogram: false
|
||||
},
|
||||
highlightCards: true
|
||||
},
|
||||
renderingCompleted: sinon.spy(),
|
||||
hiddenSeries: {},
|
||||
dashboard: {
|
||||
getTimezone: sinon.stub().returns('utc')
|
||||
},
|
||||
range: {
|
||||
from: moment.utc("01 Mar 2017 10:00:00"),
|
||||
to: moment.utc("01 Mar 2017 11:00:00"),
|
||||
},
|
||||
};
|
||||
|
||||
var scope = $rootScope.$new();
|
||||
scope.ctrl = ctrl;
|
||||
|
||||
ctx.series = [];
|
||||
ctx.series.push(new TimeSeries({
|
||||
datapoints: [[1, 1422774000000], [2, 1422774060000]],
|
||||
alias: 'series1'
|
||||
}));
|
||||
ctx.series.push(new TimeSeries({
|
||||
datapoints: [[2, 1422774000000], [3, 1422774060000]],
|
||||
alias: 'series2'
|
||||
}));
|
||||
|
||||
ctx.data = {
|
||||
heatmapStats: {
|
||||
min: 1,
|
||||
max: 3,
|
||||
minLog: 1
|
||||
},
|
||||
xBucketSize: ctrl.panel.xBucketSize,
|
||||
yBucketSize: ctrl.panel.yBucketSize
|
||||
};
|
||||
|
||||
setupFunc(ctrl, ctx);
|
||||
|
||||
let logBase = ctrl.panel.yAxis.logBase;
|
||||
let bucketsData = convertToHeatMap(ctx.series, ctx.data.yBucketSize, ctx.data.xBucketSize, logBase);
|
||||
ctx.data.buckets = bucketsData;
|
||||
|
||||
// console.log("bucketsData", bucketsData);
|
||||
// console.log("series", ctrl.panel.yAxis.logBase, ctx.series.length);
|
||||
|
||||
let elemHtml = `
|
||||
<div class="heatmap-wrapper">
|
||||
<div class="heatmap-canvas-wrapper">
|
||||
<div class="heatmap-panel" style='width:${elementWidth}px'></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
var element = angular.element(elemHtml);
|
||||
$compile(element)(scope);
|
||||
scope.$digest();
|
||||
|
||||
ctrl.data = ctx.data;
|
||||
ctx.element = element;
|
||||
let render = rendering(scope, $(element), [], ctrl);
|
||||
ctrl.events.emit('render');
|
||||
}));
|
||||
};
|
||||
|
||||
func(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
heatmapScenario('default options', function (ctx) {
|
||||
ctx.setup(function (ctrl) {
|
||||
ctrl.panel.yAxis.logBase = 1;
|
||||
});
|
||||
|
||||
it('should draw correct Y axis', function () {
|
||||
var yTicks = getTicks(ctx.element, ".axis-y");
|
||||
expect(yTicks).to.eql(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('should draw correct X axis', function () {
|
||||
var xTicks = getTicks(ctx.element, ".axis-x");
|
||||
let expectedTicks = [
|
||||
formatLocalTime("01 Mar 2017 10:00:00"),
|
||||
formatLocalTime("01 Mar 2017 10:15:00"),
|
||||
formatLocalTime("01 Mar 2017 10:30:00"),
|
||||
formatLocalTime("01 Mar 2017 10:45:00"),
|
||||
formatLocalTime("01 Mar 2017 11:00:00")
|
||||
];
|
||||
expect(xTicks).to.eql(expectedTicks);
|
||||
});
|
||||
});
|
||||
|
||||
heatmapScenario('when logBase is 2', function (ctx) {
|
||||
ctx.setup(function (ctrl) {
|
||||
ctrl.panel.yAxis.logBase = 2;
|
||||
});
|
||||
|
||||
it('should draw correct Y axis', function () {
|
||||
var yTicks = getTicks(ctx.element, ".axis-y");
|
||||
expect(yTicks).to.eql(['1', '2', '4']);
|
||||
});
|
||||
});
|
||||
|
||||
heatmapScenario('when logBase is 10', function (ctx) {
|
||||
ctx.setup(function (ctrl, ctx) {
|
||||
ctrl.panel.yAxis.logBase = 10;
|
||||
|
||||
ctx.series.push(new TimeSeries({
|
||||
datapoints: [[10, 1422774000000], [20, 1422774060000]],
|
||||
alias: 'series3'
|
||||
}));
|
||||
ctx.data.heatmapStats.max = 20;
|
||||
});
|
||||
|
||||
it('should draw correct Y axis', function () {
|
||||
var yTicks = getTicks(ctx.element, ".axis-y");
|
||||
expect(yTicks).to.eql(['1', '10', '100']);
|
||||
});
|
||||
});
|
||||
|
||||
heatmapScenario('when logBase is 32', function (ctx) {
|
||||
ctx.setup(function (ctrl) {
|
||||
ctrl.panel.yAxis.logBase = 32;
|
||||
|
||||
ctx.series.push(new TimeSeries({
|
||||
datapoints: [[10, 1422774000000], [100, 1422774060000]],
|
||||
alias: 'series3'
|
||||
}));
|
||||
ctx.data.heatmapStats.max = 100;
|
||||
});
|
||||
|
||||
it('should draw correct Y axis', function () {
|
||||
var yTicks = getTicks(ctx.element, ".axis-y");
|
||||
expect(yTicks).to.eql(['1', '32', '1 K']);
|
||||
});
|
||||
});
|
||||
|
||||
heatmapScenario('when logBase is 1024', function (ctx) {
|
||||
ctx.setup(function (ctrl) {
|
||||
ctrl.panel.yAxis.logBase = 1024;
|
||||
|
||||
ctx.series.push(new TimeSeries({
|
||||
datapoints: [[2000, 1422774000000], [300000, 1422774060000]],
|
||||
alias: 'series3'
|
||||
}));
|
||||
ctx.data.heatmapStats.max = 300000;
|
||||
});
|
||||
|
||||
it('should draw correct Y axis', function () {
|
||||
var yTicks = getTicks(ctx.element, ".axis-y");
|
||||
expect(yTicks).to.eql(['1', '1 K', '1 Mil']);
|
||||
});
|
||||
});
|
||||
|
||||
heatmapScenario('when Y axis format set to "none"', function (ctx) {
|
||||
ctx.setup(function (ctrl) {
|
||||
ctrl.panel.yAxis.logBase = 1;
|
||||
ctrl.panel.yAxis.format = "none";
|
||||
ctx.data.heatmapStats.max = 10000;
|
||||
});
|
||||
|
||||
it('should draw correct Y axis', function () {
|
||||
var yTicks = getTicks(ctx.element, ".axis-y");
|
||||
expect(yTicks).to.eql(['0', '2000', '4000', '6000', '8000', '10000', '12000']);
|
||||
});
|
||||
});
|
||||
|
||||
heatmapScenario('when Y axis format set to "second"', function (ctx) {
|
||||
ctx.setup(function (ctrl) {
|
||||
ctrl.panel.yAxis.logBase = 1;
|
||||
ctrl.panel.yAxis.format = "s";
|
||||
ctx.data.heatmapStats.max = 3600;
|
||||
});
|
||||
|
||||
it('should draw correct Y axis', function () {
|
||||
var yTicks = getTicks(ctx.element, ".axis-y");
|
||||
expect(yTicks).to.eql(['0 ns', '17 min', '33 min', '50 min', '1 hour']);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
function getTicks(element, axisSelector) {
|
||||
return element.find(axisSelector).find("text")
|
||||
.map(function () {
|
||||
return this.textContent;
|
||||
}).get();
|
||||
}
|
||||
|
||||
function formatLocalTime(timeStr) {
|
||||
let format = "HH:mm";
|
||||
return moment.utc(timeStr).local().format(format);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
|
||||
<div class="table-panel-container">
|
||||
<div class="table-panel-header-bg" ng-show="ctrl.dataRaw.length>0"></div>
|
||||
<div class="table-panel-scroll" ng-show="ctrl.dataRaw.length>0">
|
||||
<div class="table-panel-header-bg" ng-show="ctrl.table.rows.length"></div>
|
||||
<div class="table-panel-scroll" ng-show="ctrl.table.rows.length">
|
||||
<table class="table-panel-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -21,10 +21,10 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="datapoints-warning" ng-show="ctrl.dataRaw.length===0">
|
||||
<span class="small" >
|
||||
No datapoints <tip>No datapoints returned from metric query</tip>
|
||||
</span>
|
||||
<div class="datapoints-warning" ng-show="ctrl.table.rows.length===0">
|
||||
<span class="small" >
|
||||
No data to show <tip>Nothing returned by data query</tip>
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-panel-footer">
|
||||
</div>
|
||||
|
@ -30,7 +30,8 @@ System.config({
|
||||
"jquery.flot.time": "vendor/flot/jquery.flot.time",
|
||||
"jquery.flot.crosshair": "vendor/flot/jquery.flot.crosshair",
|
||||
"jquery.flot.fillbelow": "vendor/flot/jquery.flot.fillbelow",
|
||||
"jquery.flot.gauge": "vendor/flot/jquery.flot.gauge"
|
||||
"jquery.flot.gauge": "vendor/flot/jquery.flot.gauge",
|
||||
"d3": "vendor/d3/d3.js"
|
||||
},
|
||||
|
||||
packages: {
|
||||
|
@ -48,6 +48,7 @@
|
||||
@import "components/panel_singlestat";
|
||||
@import "components/panel_table";
|
||||
@import "components/panel_text";
|
||||
@import "components/panel_heatmap";
|
||||
@import "components/tagsinput";
|
||||
@import "components/tables_lists";
|
||||
@import "components/search";
|
||||
|
39
public/sass/components/_panel_heatmap.scss
Normal file
39
public/sass/components/_panel_heatmap.scss
Normal file
@ -0,0 +1,39 @@
|
||||
.heatmap-canvas-wrapper {
|
||||
// position: relative;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.heatmap-panel {
|
||||
position: relative;
|
||||
|
||||
.axis .tick {
|
||||
text {
|
||||
fill: $text-color;
|
||||
color: $text-color;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
line {
|
||||
opacity: 0.4;
|
||||
stroke: $text-color-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-tooltip {
|
||||
white-space: nowrap;
|
||||
font-size: $font-size-sm;
|
||||
background-color: $graph-tooltip-bg;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.heatmap-histogram rect {
|
||||
fill: $text-color-weak;
|
||||
}
|
||||
|
||||
.heatmap-crosshair {
|
||||
line {
|
||||
stroke: darken($red,15%);
|
||||
stroke-width: 1;
|
||||
}
|
||||
}
|
@ -38,7 +38,8 @@
|
||||
"jquery.flot.time": "vendor/flot/jquery.flot.time",
|
||||
"jquery.flot.crosshair": "vendor/flot/jquery.flot.crosshair",
|
||||
"jquery.flot.fillbelow": "vendor/flot/jquery.flot.fillbelow",
|
||||
"jquery.flot.gauge": "vendor/flot/jquery.flot.gauge"
|
||||
"jquery.flot.gauge": "vendor/flot/jquery.flot.gauge",
|
||||
"d3": "vendor/d3/d3.js",
|
||||
},
|
||||
|
||||
packages: {
|
||||
|
27
public/vendor/d3/LICENSE
vendored
Normal file
27
public/vendor/d3/LICENSE
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
Copyright 2010-2016 Mike Bostock
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the author nor the names of contributors may be used to
|
||||
endorse or promote products derived from this software without specific prior
|
||||
written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
57
public/vendor/d3/README.md
vendored
Normal file
57
public/vendor/d3/README.md
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
# D3: Data-Driven Documents
|
||||
|
||||
<a href="https://d3js.org"><img src="https://d3js.org/logo.svg" align="left" hspace="10" vspace="6"></a>
|
||||
|
||||
**D3** (or **D3.js**) is a JavaScript library for visualizing data using web standards. D3 helps you bring data to life using SVG, Canvas and HTML. D3 combines powerful visualization and interaction techniques with a data-driven approach to DOM manipulation, giving you the full capabilities of modern browsers and the freedom to design the right visual interface for your data.
|
||||
|
||||
## Resources
|
||||
|
||||
* [API Reference](https://github.com/d3/d3/blob/master/API.md)
|
||||
* [Release Notes](https://github.com/d3/d3/releases)
|
||||
* [Gallery](https://github.com/d3/d3/wiki/Gallery)
|
||||
* [Examples](http://bl.ocks.org/mbostock)
|
||||
* [Wiki](https://github.com/d3/d3/wiki)
|
||||
|
||||
## Installing
|
||||
|
||||
If you use npm, `npm install d3`. Otherwise, download the [latest release](https://github.com/d3/d3/releases/latest). The released bundle supports anonymous AMD, CommonJS, and vanilla environments. You can load directly from [d3js.org](https://d3js.org), [CDNJS](https://cdnjs.com/libraries/d3), or [unpkg](https://unpkg.com/d3/). For example:
|
||||
|
||||
```html
|
||||
<script src="https://d3js.org/d3.v4.js"></script>
|
||||
```
|
||||
|
||||
For the minified version:
|
||||
|
||||
```html
|
||||
<script src="https://d3js.org/d3.v4.min.js"></script>
|
||||
```
|
||||
|
||||
You can also use the standalone D3 microlibraries. For example, [d3-selection](https://github.com/d3/d3-selection):
|
||||
|
||||
```html
|
||||
<script src="https://d3js.org/d3-selection.v1.js"></script>
|
||||
```
|
||||
|
||||
D3 is written using [ES2015 modules](http://www.2ality.com/2014/09/es6-modules-final.html). Create a [custom bundle using Rollup](http://bl.ocks.org/mbostock/bb09af4c39c79cffcde4), Webpack, or your preferred bundler. To import D3 into an ES2015 application, either import specific symbols from specific D3 modules:
|
||||
|
||||
```js
|
||||
import {scaleLinear} from "d3-scale";
|
||||
```
|
||||
|
||||
Or import everything into a namespace (here, `d3`):
|
||||
|
||||
```js
|
||||
import * as d3 from "d3";
|
||||
```
|
||||
|
||||
In Node:
|
||||
|
||||
```js
|
||||
var d3 = require("d3");
|
||||
```
|
||||
|
||||
You can also require individual modules and combine them into a `d3` object using [Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign):
|
||||
|
||||
```js
|
||||
var d3 = Object.assign({}, require("d3-format"), require("d3-geo"), require("d3-geo-projection"));
|
||||
```
|
2
public/vendor/d3/d3-scale-chromatic.min.js
vendored
Normal file
2
public/vendor/d3/d3-scale-chromatic.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
public/vendor/d3/d3.js
vendored
Normal file
3
public/vendor/d3/d3.js
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
// Import main D3.js module and combine it with another
|
||||
var d3 = Object.assign({}, require('./d3.v4.min.js'), require('./d3-scale-chromatic.min.js'));
|
||||
module.exports = d3;
|
8
public/vendor/d3/d3.v4.min.js
vendored
Normal file
8
public/vendor/d3/d3.v4.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user