diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 9f216c12288..35886aa5bf7 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -21,699 +21,730 @@ import { convertToHistogramData } from './histogram'; import { alignYLevel } from './align_yaxes'; import config from 'app/core/config'; +import { GraphCtrl } from './module'; + +class GraphElement { + ctrl: GraphCtrl; + tooltip: any; + dashboard: any; + annotations: Array; + panel: any; + plot: any; + sortedSeries: Array; + data: Array; + panelWidth: number; + eventManager: EventManager; + thresholdManager: ThresholdManager; + + constructor(private scope, private elem, private timeSrv) { + this.ctrl = scope.ctrl; + this.dashboard = this.ctrl.dashboard; + this.panel = this.ctrl.panel; + this.annotations = []; + + this.panelWidth = 0; + this.eventManager = new EventManager(this.ctrl); + this.thresholdManager = new ThresholdManager(this.ctrl); + this.tooltip = new GraphTooltip(this.elem, this.ctrl.dashboard, this.scope, () => { + return this.sortedSeries; + }); + + // panel events + this.ctrl.events.on('panel-teardown', this.onPanelteardown.bind(this)); + + /** + * Split graph rendering into two parts. + * First, calculate series stats in buildFlotPairs() function. Then legend rendering started + * (see ctrl.events.on('render') in legend.ts). + * When legend is rendered it emits 'legend-rendering-complete' and graph rendered. + */ + this.ctrl.events.on('render', this.onRender.bind(this)); + this.ctrl.events.on('legend-rendering-complete', this.onLegendRenderingComplete.bind(this)); + + // global events + appEvents.on('graph-hover', this.onGraphHover.bind(this), scope); + + appEvents.on('graph-hover-clear', this.onGraphHoverClear.bind(this), scope); + + this.elem.bind('plotselected', this.onPlotSelected.bind(this)); + + this.elem.bind('plotclick', this.onPlotClick.bind(this)); + scope.$on('$destroy', this.onScopeDestroy.bind(this)); + } + + onRender(renderData) { + this.data = renderData || this.data; + if (!this.data) { + return; + } + this.annotations = this.ctrl.annotations || []; + this.buildFlotPairs(this.data); + const graphHeight = this.elem.height(); + updateLegendValues(this.data, this.panel, graphHeight); + + this.ctrl.events.emit('render-legend'); + } + + onGraphHover(evt) { + // ignore other graph hover events if shared tooltip is disabled + if (!this.dashboard.sharedTooltipModeEnabled()) { + return; + } + + // ignore if we are the emitter + if (!this.plot || evt.panel.id === this.panel.id || this.ctrl.otherPanelInFullscreenMode()) { + return; + } + + this.tooltip.show(evt.pos); + } + + onPanelteardown() { + this.thresholdManager = null; + + if (this.plot) { + this.plot.destroy(); + this.plot = null; + } + } + + onLegendRenderingComplete() { + this.render_panel(); + } + + onGraphHoverClear(event, info) { + if (this.plot) { + this.tooltip.clear(this.plot); + } + } + + onPlotSelected(event, ranges) { + if (this.panel.xaxis.mode !== 'time') { + // Skip if panel in histogram or series mode + this.plot.clearSelection(); + return; + } + + if ((ranges.ctrlKey || ranges.metaKey) && (this.dashboard.meta.canEdit || this.dashboard.meta.canMakeEditable)) { + // Add annotation + setTimeout(() => { + this.eventManager.updateTime(ranges.xaxis); + }, 100); + } else { + this.scope.$apply(() => { + this.timeSrv.setTime({ + from: moment.utc(ranges.xaxis.from), + to: moment.utc(ranges.xaxis.to), + }); + }); + } + } + + onPlotClick(event, pos, item) { + if (this.panel.xaxis.mode !== 'time') { + // Skip if panel in histogram or series mode + return; + } + + if ((pos.ctrlKey || pos.metaKey) && (this.dashboard.meta.canEdit || this.dashboard.meta.canMakeEditable)) { + // Skip if range selected (added in "plotselected" event handler) + let isRangeSelection = pos.x !== pos.x1; + if (!isRangeSelection) { + setTimeout(() => { + this.eventManager.updateTime({ from: pos.x, to: null }); + }, 100); + } + } + } + + onScopeDestroy() { + this.tooltip.destroy(); + this.elem.off(); + this.elem.remove(); + } + + shouldAbortRender() { + if (!this.data) { + return true; + } + + if (this.panelWidth === 0) { + return true; + } + + return false; + } + + drawHook(plot) { + // add left axis labels + if (this.panel.yaxes[0].label && this.panel.yaxes[0].show) { + $("
") + .text(this.panel.yaxes[0].label) + .appendTo(this.elem); + } + + // add right axis labels + if (this.panel.yaxes[1].label && this.panel.yaxes[1].show) { + $("
") + .text(this.panel.yaxes[1].label) + .appendTo(this.elem); + } + + if (this.ctrl.dataWarning) { + $(`
${this.ctrl.dataWarning.title}
`).appendTo(this.elem); + } + + this.thresholdManager.draw(plot); + } + + processOffsetHook(plot, gridMargin) { + var left = this.panel.yaxes[0]; + var right = this.panel.yaxes[1]; + if (left.show && left.label) { + gridMargin.left = 20; + } + if (right.show && right.label) { + gridMargin.right = 20; + } + + // apply y-axis min/max options + var yaxis = plot.getYAxes(); + for (var i = 0; i < yaxis.length; i++) { + var axis = yaxis[i]; + var panelOptions = this.panel.yaxes[i]; + axis.options.max = axis.options.max !== null ? axis.options.max : panelOptions.max; + axis.options.min = axis.options.min !== null ? axis.options.min : panelOptions.min; + } + } + + processRangeHook(plot) { + var yAxes = plot.getYAxes(); + const align = this.panel.yaxis.align || false; + + if (yAxes.length > 1 && align === true) { + const level = this.panel.yaxis.alignLevel || 0; + alignYLevel(yAxes, parseFloat(level)); + } + } + + // Series could have different timeSteps, + // let's find the smallest one so that bars are correctly rendered. + // In addition, only take series which are rendered as bars for this. + getMinTimeStepOfSeries(data) { + var min = Number.MAX_VALUE; + + for (let i = 0; i < data.length; i++) { + if (!data[i].stats.timeStep) { + continue; + } + if (this.panel.bars) { + if (data[i].bars && data[i].bars.show === false) { + continue; + } + } else { + if (typeof data[i].bars === 'undefined' || typeof data[i].bars.show === 'undefined' || !data[i].bars.show) { + continue; + } + } + + if (data[i].stats.timeStep < min) { + min = data[i].stats.timeStep; + } + } + + return min; + } + + // Function for rendering panel + render_panel() { + this.panelWidth = this.elem.width(); + if (this.shouldAbortRender()) { + return; + } + + // give space to alert editing + this.thresholdManager.prepare(this.elem, this.data); + + // un-check dashes if lines are unchecked + this.panel.dashes = this.panel.lines ? this.panel.dashes : false; + + // Populate element + let options: any = this.buildFlotOptions(this.panel); + this.prepareXAxis(options, this.panel); + this.configureYAxisOptions(this.data, options); + this.thresholdManager.addFlotOptions(options, this.panel); + this.eventManager.addFlotEvents(this.annotations, options); + + this.sortedSeries = this.sortSeries(this.data, this.panel); + this.callPlot(options, true); + } + + buildFlotPairs(data) { + for (let i = 0; i < data.length; i++) { + let series = data[i]; + series.data = series.getFlotPairs(series.nullPointMode || this.panel.nullPointMode); + + // if hidden remove points and disable stack + if (this.ctrl.hiddenSeries[series.alias]) { + series.data = []; + series.stack = false; + } + } + } + + prepareXAxis(options, panel) { + switch (panel.xaxis.mode) { + case 'series': { + options.series.bars.barWidth = 0.7; + options.series.bars.align = 'center'; + + for (let i = 0; i < this.data.length; i++) { + let series = this.data[i]; + series.data = [[i + 1, series.stats[panel.xaxis.values[0]]]]; + } + + this.addXSeriesAxis(options); + break; + } + case 'histogram': { + let bucketSize: number; + + if (this.data.length) { + let histMin = _.min(_.map(this.data, s => s.stats.min)); + let histMax = _.max(_.map(this.data, s => s.stats.max)); + let ticks = panel.xaxis.buckets || this.panelWidth / 50; + bucketSize = tickStep(histMin, histMax, ticks); + options.series.bars.barWidth = bucketSize * 0.8; + this.data = convertToHistogramData(this.data, bucketSize, this.ctrl.hiddenSeries, histMin, histMax); + } else { + bucketSize = 0; + } + + this.addXHistogramAxis(options, bucketSize); + break; + } + case 'table': { + options.series.bars.barWidth = 0.7; + options.series.bars.align = 'center'; + this.addXTableAxis(options); + break; + } + default: { + options.series.bars.barWidth = this.getMinTimeStepOfSeries(this.data) / 1.5; + this.addTimeAxis(options); + break; + } + } + } + + callPlot(options, incrementRenderCounter) { + try { + this.plot = $.plot(this.elem, this.sortedSeries, options); + if (this.ctrl.renderError) { + delete this.ctrl.error; + delete this.ctrl.inspector; + } + } catch (e) { + console.log('flotcharts error', e); + this.ctrl.error = e.message || 'Render Error'; + this.ctrl.renderError = true; + this.ctrl.inspector = { error: e }; + } + + if (incrementRenderCounter) { + this.ctrl.renderingCompleted(); + } + } + + buildFlotOptions(panel) { + let gridColor = '#c8c8c8'; + if (config.bootData.user.lightTheme === true) { + gridColor = '#a1a1a1'; + } + const stack = panel.stack ? true : null; + let options = { + hooks: { + draw: [this.drawHook.bind(this)], + processOffset: [this.processOffsetHook.bind(this)], + processRange: [this.processRangeHook.bind(this)], + }, + legend: { show: false }, + series: { + stackpercent: panel.stack ? panel.percentage : false, + stack: panel.percentage ? null : stack, + lines: { + show: panel.lines, + zero: false, + fill: this.translateFillOption(panel.fill), + lineWidth: panel.dashes ? 0 : panel.linewidth, + steps: panel.steppedLine, + }, + dashes: { + show: panel.dashes, + lineWidth: panel.linewidth, + dashLength: [panel.dashLength, panel.spaceLength], + }, + bars: { + show: panel.bars, + fill: 1, + barWidth: 1, + zero: false, + lineWidth: 0, + }, + points: { + show: panel.points, + fill: 1, + fillColor: false, + radius: panel.points ? panel.pointradius : 2, + }, + shadowSize: 0, + }, + yaxes: [], + xaxis: {}, + grid: { + minBorderMargin: 0, + markings: [], + backgroundColor: null, + borderWidth: 0, + hoverable: true, + clickable: true, + color: gridColor, + margin: { left: 0, right: 0 }, + labelMarginX: 0, + }, + selection: { + mode: 'x', + color: '#666', + }, + crosshair: { + mode: 'x', + }, + }; + return options; + } + + sortSeries(series, panel) { + var sortBy = panel.legend.sort; + var sortOrder = panel.legend.sortDesc; + var haveSortBy = sortBy !== null && sortBy !== undefined; + var haveSortOrder = sortOrder !== null && sortOrder !== undefined; + var shouldSortBy = panel.stack && haveSortBy && haveSortOrder; + var sortDesc = panel.legend.sortDesc === true ? -1 : 1; + + if (shouldSortBy) { + return _.sortBy(series, s => s.stats[sortBy] * sortDesc); + } else { + return _.sortBy(series, s => s.zindex); + } + } + + translateFillOption(fill) { + if (this.panel.percentage && this.panel.stack) { + return fill === 0 ? 0.001 : fill / 10; + } else { + return fill / 10; + } + } + + addTimeAxis(options) { + var ticks = this.panelWidth / 100; + var min = _.isUndefined(this.ctrl.range.from) ? null : this.ctrl.range.from.valueOf(); + var max = _.isUndefined(this.ctrl.range.to) ? null : this.ctrl.range.to.valueOf(); + + options.xaxis = { + timezone: this.dashboard.getTimezone(), + show: this.panel.xaxis.show, + mode: 'time', + min: min, + max: max, + label: 'Datetime', + ticks: ticks, + timeformat: this.time_format(ticks, min, max), + }; + } + + addXSeriesAxis(options) { + var ticks = _.map(this.data, function(series, index) { + return [index + 1, series.alias]; + }); + + options.xaxis = { + timezone: this.dashboard.getTimezone(), + show: this.panel.xaxis.show, + mode: null, + min: 0, + max: ticks.length + 1, + label: 'Datetime', + ticks: ticks, + }; + } + + addXHistogramAxis(options, bucketSize) { + let ticks, min, max; + let defaultTicks = this.panelWidth / 50; + + if (this.data.length && bucketSize) { + let tick_values = []; + for (let d of this.data) { + for (let point of d.data) { + tick_values[point[0]] = true; + } + } + ticks = Object.keys(tick_values).map(v => Number(v)); + min = _.min(ticks); + max = _.max(ticks); + + // Adjust tick step + let tickStep = bucketSize; + let ticks_num = Math.floor((max - min) / tickStep); + while (ticks_num > defaultTicks) { + tickStep = tickStep * 2; + ticks_num = Math.ceil((max - min) / tickStep); + } + + // Expand ticks for pretty view + min = Math.floor(min / tickStep) * tickStep; + // 1.01 is 101% - ensure we have enough space for last bar + max = Math.ceil(max * 1.01 / tickStep) * tickStep; + + ticks = []; + for (let i = min; i <= max; i += tickStep) { + ticks.push(i); + } + } else { + // Set defaults if no data + ticks = defaultTicks / 2; + min = 0; + max = 1; + } + + options.xaxis = { + timezone: this.dashboard.getTimezone(), + show: this.panel.xaxis.show, + mode: null, + min: min, + max: max, + label: 'Histogram', + ticks: ticks, + }; + + // Use 'short' format for histogram values + this.configureAxisMode(options.xaxis, 'short'); + } + + addXTableAxis(options) { + var ticks = _.map(this.data, function(series, seriesIndex) { + return _.map(series.datapoints, function(point, pointIndex) { + var tickIndex = seriesIndex * series.datapoints.length + pointIndex; + return [tickIndex + 1, point[1]]; + }); + }); + ticks = _.flatten(ticks, true); + + options.xaxis = { + timezone: this.dashboard.getTimezone(), + show: this.panel.xaxis.show, + mode: null, + min: 0, + max: ticks.length + 1, + label: 'Datetime', + ticks: ticks, + }; + } + + configureYAxisOptions(data, options) { + var defaults = { + position: 'left', + show: this.panel.yaxes[0].show, + index: 1, + logBase: this.panel.yaxes[0].logBase || 1, + min: this.parseNumber(this.panel.yaxes[0].min), + max: this.parseNumber(this.panel.yaxes[0].max), + tickDecimals: this.panel.yaxes[0].decimals, + }; + + options.yaxes.push(defaults); + + if (_.find(data, { yaxis: 2 })) { + var secondY = _.clone(defaults); + secondY.index = 2; + secondY.show = this.panel.yaxes[1].show; + secondY.logBase = this.panel.yaxes[1].logBase || 1; + secondY.position = 'right'; + secondY.min = this.parseNumber(this.panel.yaxes[1].min); + secondY.max = this.parseNumber(this.panel.yaxes[1].max); + secondY.tickDecimals = this.panel.yaxes[1].decimals; + options.yaxes.push(secondY); + + this.applyLogScale(options.yaxes[1], data); + this.configureAxisMode( + options.yaxes[1], + this.panel.percentage && this.panel.stack ? 'percent' : this.panel.yaxes[1].format + ); + } + this.applyLogScale(options.yaxes[0], data); + this.configureAxisMode( + options.yaxes[0], + this.panel.percentage && this.panel.stack ? 'percent' : this.panel.yaxes[0].format + ); + } + + parseNumber(value: any) { + if (value === null || typeof value === 'undefined') { + return null; + } + + return _.toNumber(value); + } + + applyLogScale(axis, data) { + if (axis.logBase === 1) { + return; + } + + const minSetToZero = axis.min === 0; + + if (axis.min < Number.MIN_VALUE) { + axis.min = null; + } + if (axis.max < Number.MIN_VALUE) { + axis.max = null; + } + + var series, i; + var max = axis.max, + min = axis.min; + + for (i = 0; i < data.length; i++) { + series = data[i]; + if (series.yaxis === axis.index) { + if (!max || max < series.stats.max) { + max = series.stats.max; + } + if (!min || min > series.stats.logmin) { + min = series.stats.logmin; + } + } + } + + axis.transform = function(v) { + return v < Number.MIN_VALUE ? null : Math.log(v) / Math.log(axis.logBase); + }; + axis.inverseTransform = function(v) { + return Math.pow(axis.logBase, v); + }; + + if (!max && !min) { + max = axis.inverseTransform(+2); + min = axis.inverseTransform(-2); + } else if (!max) { + max = min * axis.inverseTransform(+4); + } else if (!min) { + min = max * axis.inverseTransform(-4); + } + + if (axis.min) { + min = axis.inverseTransform(Math.ceil(axis.transform(axis.min))); + } else { + min = axis.min = axis.inverseTransform(Math.floor(axis.transform(min))); + } + if (axis.max) { + max = axis.inverseTransform(Math.floor(axis.transform(axis.max))); + } else { + max = axis.max = axis.inverseTransform(Math.ceil(axis.transform(max))); + } + + if (!min || min < Number.MIN_VALUE || !max || max < Number.MIN_VALUE) { + return; + } + + if (Number.isFinite(min) && Number.isFinite(max)) { + if (minSetToZero) { + axis.min = 0.1; + min = 1; + } + + axis.ticks = this.generateTicksForLogScaleYAxis(min, max, axis.logBase); + if (minSetToZero) { + axis.ticks.unshift(0.1); + } + if (axis.ticks[axis.ticks.length - 1] > axis.max) { + axis.max = axis.ticks[axis.ticks.length - 1]; + } + } else { + axis.ticks = [1, 2]; + delete axis.min; + delete axis.max; + } + } + + generateTicksForLogScaleYAxis(min, max, logBase) { + let ticks = []; + + var nextTick; + for (nextTick = min; nextTick <= max; nextTick *= logBase) { + ticks.push(nextTick); + } + + const maxNumTicks = Math.ceil(this.ctrl.height / 25); + const numTicks = ticks.length; + if (numTicks > maxNumTicks) { + const factor = Math.ceil(numTicks / maxNumTicks) * logBase; + ticks = []; + + for (nextTick = min; nextTick <= max * factor; nextTick *= factor) { + ticks.push(nextTick); + } + } + + return ticks; + } + + configureAxisMode(axis, format) { + axis.tickFormatter = function(val, axis) { + if (!kbn.valueFormats[format]) { + throw new Error(`Unit '${format}' is not supported`); + } + return kbn.valueFormats[format](val, axis.tickDecimals, axis.scaledDecimals); + }; + } + + time_format(ticks, min, max) { + if (min && max && ticks) { + var range = max - min; + var secPerTick = range / ticks / 1000; + var oneDay = 86400000; + var 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'; + } +} + /** @ngInject **/ function graphDirective(timeSrv, popoverSrv, contextSrv) { return { restrict: 'A', template: '', - link: function(scope, elem) { - var ctrl = scope.ctrl; - var dashboard = ctrl.dashboard; - var panel = ctrl.panel; - var annotations = []; - var data; - var plot; - var sortedSeries; - var panelWidth = 0; - var eventManager = new EventManager(ctrl); - var thresholdManager = new ThresholdManager(ctrl); - var tooltip = new GraphTooltip(elem, dashboard, scope, function() { - return sortedSeries; - }); - - // panel events - ctrl.events.on('panel-teardown', () => { - thresholdManager = null; - - if (plot) { - plot.destroy(); - plot = null; - } - }); - - /** - * Split graph rendering into two parts. - * First, calculate series stats in buildFlotPairs() function. Then legend rendering started - * (see ctrl.events.on('render') in legend.ts). - * When legend is rendered it emits 'legend-rendering-complete' and graph rendered. - */ - ctrl.events.on('render', renderData => { - data = renderData || data; - if (!data) { - return; - } - annotations = ctrl.annotations || []; - buildFlotPairs(data); - const graphHeight = elem.height(); - updateLegendValues(data, panel, graphHeight); - - ctrl.events.emit('render-legend'); - }); - - ctrl.events.on('legend-rendering-complete', () => { - render_panel(); - }); - - // global events - appEvents.on( - 'graph-hover', - evt => { - // ignore other graph hover events if shared tooltip is disabled - if (!dashboard.sharedTooltipModeEnabled()) { - return; - } - - // ignore if we are the emitter - if (!plot || evt.panel.id === panel.id || ctrl.otherPanelInFullscreenMode()) { - return; - } - - tooltip.show(evt.pos); - }, - scope - ); - - appEvents.on( - 'graph-hover-clear', - (event, info) => { - if (plot) { - tooltip.clear(plot); - } - }, - scope - ); - - function shouldAbortRender() { - if (!data) { - return true; - } - - if (panelWidth === 0) { - return true; - } - - return false; - } - - function drawHook(plot) { - // add left axis labels - if (panel.yaxes[0].label && panel.yaxes[0].show) { - $("
") - .text(panel.yaxes[0].label) - .appendTo(elem); - } - - // add right axis labels - if (panel.yaxes[1].label && panel.yaxes[1].show) { - $("
") - .text(panel.yaxes[1].label) - .appendTo(elem); - } - - if (ctrl.dataWarning) { - $(`
${ctrl.dataWarning.title}
`).appendTo(elem); - } - - thresholdManager.draw(plot); - } - - function processOffsetHook(plot, gridMargin) { - var left = panel.yaxes[0]; - var right = panel.yaxes[1]; - if (left.show && left.label) { - gridMargin.left = 20; - } - if (right.show && right.label) { - gridMargin.right = 20; - } - - // apply y-axis min/max options - var yaxis = plot.getYAxes(); - for (var i = 0; i < yaxis.length; i++) { - var axis = yaxis[i]; - var panelOptions = panel.yaxes[i]; - axis.options.max = axis.options.max !== null ? axis.options.max : panelOptions.max; - axis.options.min = axis.options.min !== null ? axis.options.min : panelOptions.min; - } - } - - function processRangeHook(plot) { - var yAxes = plot.getYAxes(); - const align = panel.yaxis.align || false; - - if (yAxes.length > 1 && align === true) { - const level = panel.yaxis.alignLevel || 0; - alignYLevel(yAxes, parseFloat(level)); - } - } - - // Series could have different timeSteps, - // let's find the smallest one so that bars are correctly rendered. - // In addition, only take series which are rendered as bars for this. - function getMinTimeStepOfSeries(data) { - var min = Number.MAX_VALUE; - - for (let i = 0; i < data.length; i++) { - if (!data[i].stats.timeStep) { - continue; - } - if (panel.bars) { - if (data[i].bars && data[i].bars.show === false) { - continue; - } - } else { - if (typeof data[i].bars === 'undefined' || typeof data[i].bars.show === 'undefined' || !data[i].bars.show) { - continue; - } - } - - if (data[i].stats.timeStep < min) { - min = data[i].stats.timeStep; - } - } - - return min; - } - - // Function for rendering panel - function render_panel() { - panelWidth = elem.width(); - if (shouldAbortRender()) { - return; - } - - // give space to alert editing - thresholdManager.prepare(elem, data); - - // un-check dashes if lines are unchecked - panel.dashes = panel.lines ? panel.dashes : false; - - // Populate element - let options: any = buildFlotOptions(panel); - prepareXAxis(options, panel); - configureYAxisOptions(data, options); - thresholdManager.addFlotOptions(options, panel); - eventManager.addFlotEvents(annotations, options); - - sortedSeries = sortSeries(data, panel); - callPlot(options, true); - } - - function buildFlotPairs(data) { - for (let i = 0; i < data.length; i++) { - let series = data[i]; - series.data = series.getFlotPairs(series.nullPointMode || panel.nullPointMode); - - // if hidden remove points and disable stack - if (ctrl.hiddenSeries[series.alias]) { - series.data = []; - series.stack = false; - } - } - } - - function prepareXAxis(options, panel) { - switch (panel.xaxis.mode) { - case 'series': { - options.series.bars.barWidth = 0.7; - options.series.bars.align = 'center'; - - for (let i = 0; i < data.length; i++) { - let series = data[i]; - series.data = [[i + 1, series.stats[panel.xaxis.values[0]]]]; - } - - addXSeriesAxis(options); - break; - } - case 'histogram': { - let bucketSize: number; - - if (data.length) { - let histMin = _.min(_.map(data, s => s.stats.min)); - let histMax = _.max(_.map(data, s => s.stats.max)); - let ticks = panel.xaxis.buckets || panelWidth / 50; - bucketSize = tickStep(histMin, histMax, ticks); - options.series.bars.barWidth = bucketSize * 0.8; - data = convertToHistogramData(data, bucketSize, ctrl.hiddenSeries, histMin, histMax); - } else { - bucketSize = 0; - } - - addXHistogramAxis(options, bucketSize); - break; - } - case 'table': { - options.series.bars.barWidth = 0.7; - options.series.bars.align = 'center'; - addXTableAxis(options); - break; - } - default: { - options.series.bars.barWidth = getMinTimeStepOfSeries(data) / 1.5; - addTimeAxis(options); - break; - } - } - } - - function callPlot(options, incrementRenderCounter) { - try { - plot = $.plot(elem, sortedSeries, options); - if (ctrl.renderError) { - delete ctrl.error; - delete ctrl.inspector; - } - } catch (e) { - console.log('flotcharts error', e); - ctrl.error = e.message || 'Render Error'; - ctrl.renderError = true; - ctrl.inspector = { error: e }; - } - - if (incrementRenderCounter) { - ctrl.renderingCompleted(); - } - } - - function buildFlotOptions(panel) { - let gridColor = '#c8c8c8'; - if (config.bootData.user.lightTheme === true) { - gridColor = '#a1a1a1'; - } - const stack = panel.stack ? true : null; - let options = { - hooks: { - draw: [drawHook], - processOffset: [processOffsetHook], - processRange: [processRangeHook], - }, - legend: { show: false }, - series: { - stackpercent: panel.stack ? panel.percentage : false, - stack: panel.percentage ? null : stack, - lines: { - show: panel.lines, - zero: false, - fill: translateFillOption(panel.fill), - lineWidth: panel.dashes ? 0 : panel.linewidth, - steps: panel.steppedLine, - }, - dashes: { - show: panel.dashes, - lineWidth: panel.linewidth, - dashLength: [panel.dashLength, panel.spaceLength], - }, - bars: { - show: panel.bars, - fill: 1, - barWidth: 1, - zero: false, - lineWidth: 0, - }, - points: { - show: panel.points, - fill: 1, - fillColor: false, - radius: panel.points ? panel.pointradius : 2, - }, - shadowSize: 0, - }, - yaxes: [], - xaxis: {}, - grid: { - minBorderMargin: 0, - markings: [], - backgroundColor: null, - borderWidth: 0, - hoverable: true, - clickable: true, - color: gridColor, - margin: { left: 0, right: 0 }, - labelMarginX: 0, - }, - selection: { - mode: 'x', - color: '#666', - }, - crosshair: { - mode: 'x', - }, - }; - return options; - } - - function sortSeries(series, panel) { - var sortBy = panel.legend.sort; - var sortOrder = panel.legend.sortDesc; - var haveSortBy = sortBy !== null && sortBy !== undefined; - var haveSortOrder = sortOrder !== null && sortOrder !== undefined; - var shouldSortBy = panel.stack && haveSortBy && haveSortOrder; - var sortDesc = panel.legend.sortDesc === true ? -1 : 1; - - if (shouldSortBy) { - return _.sortBy(series, s => s.stats[sortBy] * sortDesc); - } else { - return _.sortBy(series, s => s.zindex); - } - } - - function translateFillOption(fill) { - if (panel.percentage && panel.stack) { - return fill === 0 ? 0.001 : fill / 10; - } else { - return fill / 10; - } - } - - function addTimeAxis(options) { - var ticks = panelWidth / 100; - var min = _.isUndefined(ctrl.range.from) ? null : ctrl.range.from.valueOf(); - var max = _.isUndefined(ctrl.range.to) ? null : ctrl.range.to.valueOf(); - - options.xaxis = { - timezone: dashboard.getTimezone(), - show: panel.xaxis.show, - mode: 'time', - min: min, - max: max, - label: 'Datetime', - ticks: ticks, - timeformat: time_format(ticks, min, max), - }; - } - - function addXSeriesAxis(options) { - var ticks = _.map(data, function(series, index) { - return [index + 1, series.alias]; - }); - - options.xaxis = { - timezone: dashboard.getTimezone(), - show: panel.xaxis.show, - mode: null, - min: 0, - max: ticks.length + 1, - label: 'Datetime', - ticks: ticks, - }; - } - - function addXHistogramAxis(options, bucketSize) { - let ticks, min, max; - let defaultTicks = panelWidth / 50; - - if (data.length && bucketSize) { - let tick_values = []; - for (let d of data) { - for (let point of d.data) { - tick_values[point[0]] = true; - } - } - ticks = Object.keys(tick_values).map(v => Number(v)); - min = _.min(ticks); - max = _.max(ticks); - - // Adjust tick step - let tickStep = bucketSize; - let ticks_num = Math.floor((max - min) / tickStep); - while (ticks_num > defaultTicks) { - tickStep = tickStep * 2; - ticks_num = Math.ceil((max - min) / tickStep); - } - - // Expand ticks for pretty view - min = Math.floor(min / tickStep) * tickStep; - // 1.01 is 101% - ensure we have enough space for last bar - max = Math.ceil(max * 1.01 / tickStep) * tickStep; - - ticks = []; - for (let i = min; i <= max; i += tickStep) { - ticks.push(i); - } - } else { - // Set defaults if no data - ticks = defaultTicks / 2; - min = 0; - max = 1; - } - - options.xaxis = { - timezone: dashboard.getTimezone(), - show: panel.xaxis.show, - mode: null, - min: min, - max: max, - label: 'Histogram', - ticks: ticks, - }; - - // Use 'short' format for histogram values - configureAxisMode(options.xaxis, 'short'); - } - - function addXTableAxis(options) { - var ticks = _.map(data, function(series, seriesIndex) { - return _.map(series.datapoints, function(point, pointIndex) { - var tickIndex = seriesIndex * series.datapoints.length + pointIndex; - return [tickIndex + 1, point[1]]; - }); - }); - ticks = _.flatten(ticks, true); - - options.xaxis = { - timezone: dashboard.getTimezone(), - show: panel.xaxis.show, - mode: null, - min: 0, - max: ticks.length + 1, - label: 'Datetime', - ticks: ticks, - }; - } - - function configureYAxisOptions(data, options) { - var defaults = { - position: 'left', - show: panel.yaxes[0].show, - index: 1, - logBase: panel.yaxes[0].logBase || 1, - min: parseNumber(panel.yaxes[0].min), - max: parseNumber(panel.yaxes[0].max), - tickDecimals: panel.yaxes[0].decimals, - }; - - options.yaxes.push(defaults); - - if (_.find(data, { yaxis: 2 })) { - var secondY = _.clone(defaults); - secondY.index = 2; - secondY.show = panel.yaxes[1].show; - secondY.logBase = panel.yaxes[1].logBase || 1; - secondY.position = 'right'; - secondY.min = parseNumber(panel.yaxes[1].min); - secondY.max = parseNumber(panel.yaxes[1].max); - secondY.tickDecimals = panel.yaxes[1].decimals; - options.yaxes.push(secondY); - - applyLogScale(options.yaxes[1], data); - configureAxisMode(options.yaxes[1], panel.percentage && panel.stack ? 'percent' : panel.yaxes[1].format); - } - applyLogScale(options.yaxes[0], data); - configureAxisMode(options.yaxes[0], panel.percentage && panel.stack ? 'percent' : panel.yaxes[0].format); - } - - function parseNumber(value: any) { - if (value === null || typeof value === 'undefined') { - return null; - } - - return _.toNumber(value); - } - - function applyLogScale(axis, data) { - if (axis.logBase === 1) { - return; - } - - const minSetToZero = axis.min === 0; - - if (axis.min < Number.MIN_VALUE) { - axis.min = null; - } - if (axis.max < Number.MIN_VALUE) { - axis.max = null; - } - - var series, i; - var max = axis.max, - min = axis.min; - - for (i = 0; i < data.length; i++) { - series = data[i]; - if (series.yaxis === axis.index) { - if (!max || max < series.stats.max) { - max = series.stats.max; - } - if (!min || min > series.stats.logmin) { - min = series.stats.logmin; - } - } - } - - axis.transform = function(v) { - return v < Number.MIN_VALUE ? null : Math.log(v) / Math.log(axis.logBase); - }; - axis.inverseTransform = function(v) { - return Math.pow(axis.logBase, v); - }; - - if (!max && !min) { - max = axis.inverseTransform(+2); - min = axis.inverseTransform(-2); - } else if (!max) { - max = min * axis.inverseTransform(+4); - } else if (!min) { - min = max * axis.inverseTransform(-4); - } - - if (axis.min) { - min = axis.inverseTransform(Math.ceil(axis.transform(axis.min))); - } else { - min = axis.min = axis.inverseTransform(Math.floor(axis.transform(min))); - } - if (axis.max) { - max = axis.inverseTransform(Math.floor(axis.transform(axis.max))); - } else { - max = axis.max = axis.inverseTransform(Math.ceil(axis.transform(max))); - } - - if (!min || min < Number.MIN_VALUE || !max || max < Number.MIN_VALUE) { - return; - } - - if (Number.isFinite(min) && Number.isFinite(max)) { - if (minSetToZero) { - axis.min = 0.1; - min = 1; - } - - axis.ticks = generateTicksForLogScaleYAxis(min, max, axis.logBase); - if (minSetToZero) { - axis.ticks.unshift(0.1); - } - if (axis.ticks[axis.ticks.length - 1] > axis.max) { - axis.max = axis.ticks[axis.ticks.length - 1]; - } - } else { - axis.ticks = [1, 2]; - delete axis.min; - delete axis.max; - } - } - - function generateTicksForLogScaleYAxis(min, max, logBase) { - let ticks = []; - - var nextTick; - for (nextTick = min; nextTick <= max; nextTick *= logBase) { - ticks.push(nextTick); - } - - const maxNumTicks = Math.ceil(ctrl.height / 25); - const numTicks = ticks.length; - if (numTicks > maxNumTicks) { - const factor = Math.ceil(numTicks / maxNumTicks) * logBase; - ticks = []; - - for (nextTick = min; nextTick <= max * factor; nextTick *= factor) { - ticks.push(nextTick); - } - } - - return ticks; - } - - function configureAxisMode(axis, format) { - axis.tickFormatter = function(val, axis) { - if (!kbn.valueFormats[format]) { - throw new Error(`Unit '${format}' is not supported`); - } - return kbn.valueFormats[format](val, axis.tickDecimals, axis.scaledDecimals); - }; - } - - function time_format(ticks, min, max) { - if (min && max && ticks) { - var range = max - min; - var secPerTick = range / ticks / 1000; - var oneDay = 86400000; - var 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'; - } - - elem.bind('plotselected', function(event, ranges) { - if (panel.xaxis.mode !== 'time') { - // Skip if panel in histogram or series mode - plot.clearSelection(); - return; - } - - if ((ranges.ctrlKey || ranges.metaKey) && (dashboard.meta.canEdit || dashboard.meta.canMakeEditable)) { - // Add annotation - setTimeout(() => { - eventManager.updateTime(ranges.xaxis); - }, 100); - } else { - scope.$apply(function() { - timeSrv.setTime({ - from: moment.utc(ranges.xaxis.from), - to: moment.utc(ranges.xaxis.to), - }); - }); - } - }); - - elem.bind('plotclick', function(event, pos, item) { - if (panel.xaxis.mode !== 'time') { - // Skip if panel in histogram or series mode - return; - } - - if ((pos.ctrlKey || pos.metaKey) && (dashboard.meta.canEdit || dashboard.meta.canMakeEditable)) { - // Skip if range selected (added in "plotselected" event handler) - let isRangeSelection = pos.x !== pos.x1; - if (!isRangeSelection) { - setTimeout(() => { - eventManager.updateTime({ from: pos.x, to: null }); - }, 100); - } - } - }); - - scope.$on('$destroy', function() { - tooltip.destroy(); - elem.off(); - elem.remove(); - }); + link: (scope, elem) => { + return new GraphElement(scope, elem, timeSrv); }, }; } coreModule.directive('grafanaGraph', graphDirective); +export { GraphElement, graphDirective }; diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index ef82fb395a5..ba151692147 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -13,6 +13,7 @@ import { axesEditorComponent } from './axes_editor'; class GraphCtrl extends MetricsPanelCtrl { static template = template; + renderError: boolean; hiddenSeries: any = {}; seriesList: any = []; dataList: any = []; diff --git a/public/app/plugins/panel/graph/specs/graph.jest.ts b/public/app/plugins/panel/graph/specs/graph.jest.ts new file mode 100644 index 00000000000..f75f7cd68ea --- /dev/null +++ b/public/app/plugins/panel/graph/specs/graph.jest.ts @@ -0,0 +1,518 @@ +jest.mock('app/features/annotations/all', () => ({ + EventManager: function() { + return { + on: () => {}, + addFlotEvents: () => {}, + }; + }, +})); + +jest.mock('app/core/core', () => ({ + coreModule: { + directive: () => {}, + }, + appEvents: { + on: () => {}, + }, +})); + +import '../module'; +import { GraphCtrl } from '../module'; +import { MetricsPanelCtrl } from 'app/features/panel/metrics_panel_ctrl'; +import { PanelCtrl } from 'app/features/panel/panel_ctrl'; + +import config from 'app/core/config'; + +import TimeSeries from 'app/core/time_series2'; +import moment from 'moment'; +import $ from 'jquery'; +import { graphDirective } from '../graph'; + +let ctx = {}; +let ctrl; +let scope = { + ctrl: {}, + range: { + from: moment([2015, 1, 1]), + to: moment([2015, 11, 20]), + }, + $on: () => {}, +}; +let link; + +describe('grafanaGraph', function() { + const setupCtx = (beforeRender?) => { + config.bootData = { + user: { + lightTheme: false, + }, + }; + GraphCtrl.prototype = { + ...MetricsPanelCtrl.prototype, + ...PanelCtrl.prototype, + ...GraphCtrl.prototype, + height: 200, + panel: { + events: { + on: () => {}, + }, + legend: {}, + grid: {}, + yaxes: [ + { + min: null, + max: null, + format: 'short', + logBase: 1, + }, + { + min: null, + max: null, + format: 'short', + logBase: 1, + }, + ], + thresholds: [], + xaxis: {}, + seriesOverrides: [], + tooltip: { + shared: true, + }, + }, + renderingCompleted: jest.fn(), + hiddenSeries: {}, + dashboard: { + getTimezone: () => 'browser', + }, + range: { + from: moment([2015, 1, 1, 10]), + to: moment([2015, 1, 1, 22]), + }, + }; + + ctx.data = []; + ctx.data.push( + new TimeSeries({ + datapoints: [[1, 1], [2, 2]], + alias: 'series1', + }) + ); + ctx.data.push( + new TimeSeries({ + datapoints: [[10, 1], [20, 2]], + alias: 'series2', + }) + ); + + ctrl = new GraphCtrl( + { + $on: () => {}, + }, + { + get: () => {}, + }, + {} + ); + + $.plot = ctrl.plot = jest.fn(); + scope.ctrl = ctrl; + + link = graphDirective({}, {}, {}).link(scope, { width: () => 500, mouseleave: () => {}, bind: () => {} }); + if (typeof beforeRender === 'function') { + beforeRender(); + } + link.data = ctx.data; + + //Emulate functions called by event listeners + link.buildFlotPairs(link.data); + link.render_panel(); + ctx.plotData = ctrl.plot.mock.calls[0][1]; + + ctx.plotOptions = ctrl.plot.mock.calls[0][2]; + }; + + describe('simple lines options', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.lines = true; + ctrl.panel.fill = 5; + ctrl.panel.linewidth = 3; + ctrl.panel.steppedLine = true; + }); + }); + + it('should configure plot with correct options', () => { + expect(ctx.plotOptions.series.lines.show).toBe(true); + expect(ctx.plotOptions.series.lines.fill).toBe(0.5); + expect(ctx.plotOptions.series.lines.lineWidth).toBe(3); + expect(ctx.plotOptions.series.lines.steps).toBe(true); + }); + }); + + describe('sorting stacked series as legend. disabled', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.legend.sort = undefined; + ctrl.panel.stack = false; + }); + }); + + it('should not modify order of time series', () => { + expect(ctx.plotData[0].alias).toBe('series1'); + expect(ctx.plotData[1].alias).toBe('series2'); + }); + }); + + describe('sorting stacked series as legend. min descending order', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.legend.sort = 'min'; + ctrl.panel.legend.sortDesc = true; + ctrl.panel.stack = true; + }); + }); + it('highest value should be first', () => { + expect(ctx.plotData[0].alias).toBe('series2'); + expect(ctx.plotData[1].alias).toBe('series1'); + }); + }); + + describe('sorting stacked series as legend. min ascending order', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.legend.sort = 'min'; + ctrl.panel.legend.sortDesc = false; + ctrl.panel.stack = true; + }); + }); + it('lowest value should be first', () => { + expect(ctx.plotData[0].alias).toBe('series1'); + expect(ctx.plotData[1].alias).toBe('series2'); + }); + }); + + describe('sorting stacked series as legend. stacking disabled', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.legend.sort = 'min'; + ctrl.panel.legend.sortDesc = true; + ctrl.panel.stack = false; + }); + }); + + it('highest value should be first', () => { + expect(ctx.plotData[0].alias).toBe('series1'); + expect(ctx.plotData[1].alias).toBe('series2'); + }); + }); + + describe('sorting stacked series as legend. current descending order', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.legend.sort = 'current'; + ctrl.panel.legend.sortDesc = true; + ctrl.panel.stack = true; + }); + }); + + it('highest last value should be first', () => { + expect(ctx.plotData[0].alias).toBe('series2'); + expect(ctx.plotData[1].alias).toBe('series1'); + }); + }); + + describe('when logBase is log 10', () => { + beforeEach(() => { + setupCtx(() => { + ctx.data[0] = new TimeSeries({ + datapoints: [[2000, 1], [0.002, 2], [0, 3], [-1, 4]], + alias: 'seriesAutoscale', + }); + ctx.data[0].yaxis = 1; + ctx.data[1] = new TimeSeries({ + datapoints: [[2000, 1], [0.002, 2], [0, 3], [-1, 4]], + alias: 'seriesFixedscale', + }); + ctx.data[1].yaxis = 2; + ctrl.panel.yaxes[0].logBase = 10; + + ctrl.panel.yaxes[1].logBase = 10; + ctrl.panel.yaxes[1].min = '0.05'; + ctrl.panel.yaxes[1].max = '1500'; + }); + }); + + it('should apply axis transform, autoscaling (if necessary) and ticks', function() { + var axisAutoscale = ctx.plotOptions.yaxes[0]; + expect(axisAutoscale.transform(100)).toBe(2); + expect(axisAutoscale.inverseTransform(-3)).toBeCloseTo(0.001); + expect(axisAutoscale.min).toBeCloseTo(0.001); + expect(axisAutoscale.max).toBe(10000); + expect(axisAutoscale.ticks.length).toBeCloseTo(8); + expect(axisAutoscale.ticks[0]).toBeCloseTo(0.001); + if (axisAutoscale.ticks.length === 7) { + expect(axisAutoscale.ticks[axisAutoscale.ticks.length - 1]).toBeCloseTo(1000); + } else { + expect(axisAutoscale.ticks[axisAutoscale.ticks.length - 1]).toBe(10000); + } + + var axisFixedscale = ctx.plotOptions.yaxes[1]; + expect(axisFixedscale.min).toBe(0.05); + expect(axisFixedscale.max).toBe(1500); + expect(axisFixedscale.ticks.length).toBe(5); + expect(axisFixedscale.ticks[0]).toBe(0.1); + expect(axisFixedscale.ticks[4]).toBe(1000); + }); + }); + + describe('when logBase is log 10 and data points contain only zeroes', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.yaxes[0].logBase = 10; + ctx.data[0] = new TimeSeries({ + datapoints: [[0, 1], [0, 2], [0, 3], [0, 4]], + alias: 'seriesAutoscale', + }); + ctx.data[0].yaxis = 1; + }); + }); + + it('should not set min and max and should create some fake ticks', function() { + var axisAutoscale = ctx.plotOptions.yaxes[0]; + expect(axisAutoscale.transform(100)).toBe(2); + expect(axisAutoscale.inverseTransform(-3)).toBeCloseTo(0.001); + expect(axisAutoscale.min).toBe(undefined); + expect(axisAutoscale.max).toBe(undefined); + expect(axisAutoscale.ticks.length).toBe(2); + expect(axisAutoscale.ticks[0]).toBe(1); + expect(axisAutoscale.ticks[1]).toBe(2); + }); + }); + + // y-min set 0 is a special case for log scale, + // this approximates it by setting min to 0.1 + describe('when logBase is log 10 and y-min is set to 0 and auto min is > 0.1', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.yaxes[0].logBase = 10; + ctrl.panel.yaxes[0].min = '0'; + ctx.data[0] = new TimeSeries({ + datapoints: [[2000, 1], [4, 2], [500, 3], [3000, 4]], + alias: 'seriesAutoscale', + }); + ctx.data[0].yaxis = 1; + }); + }); + it('should set min to 0.1 and add a tick for 0.1', function() { + var axisAutoscale = ctx.plotOptions.yaxes[0]; + expect(axisAutoscale.transform(100)).toBe(2); + expect(axisAutoscale.inverseTransform(-3)).toBeCloseTo(0.001); + expect(axisAutoscale.min).toBe(0.1); + expect(axisAutoscale.max).toBe(10000); + expect(axisAutoscale.ticks.length).toBe(6); + expect(axisAutoscale.ticks[0]).toBe(0.1); + expect(axisAutoscale.ticks[5]).toBe(10000); + }); + }); + + describe('when logBase is log 2 and y-min is set to 0 and num of ticks exceeds max', () => { + beforeEach(() => { + setupCtx(() => { + const heightForApprox5Ticks = 125; + ctrl.height = heightForApprox5Ticks; + ctrl.panel.yaxes[0].logBase = 2; + ctrl.panel.yaxes[0].min = '0'; + ctx.data[0] = new TimeSeries({ + datapoints: [[2000, 1], [4, 2], [500, 3], [3000, 4], [10000, 5], [100000, 6]], + alias: 'seriesAutoscale', + }); + ctx.data[0].yaxis = 1; + }); + }); + + it('should regenerate ticks so that if fits on the y-axis', function() { + var axisAutoscale = ctx.plotOptions.yaxes[0]; + expect(axisAutoscale.min).toBe(0.1); + expect(axisAutoscale.ticks.length).toBe(8); + expect(axisAutoscale.ticks[0]).toBe(0.1); + expect(axisAutoscale.ticks[7]).toBe(262144); + expect(axisAutoscale.max).toBe(262144); + }); + + it('should set axis max to be max tick value', function() { + expect(ctx.plotOptions.yaxes[0].max).toBe(262144); + }); + }); + + describe('dashed lines options', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.lines = true; + ctrl.panel.linewidth = 2; + ctrl.panel.dashes = true; + }); + }); + + it('should configure dashed plot with correct options', function() { + expect(ctx.plotOptions.series.lines.show).toBe(true); + expect(ctx.plotOptions.series.dashes.lineWidth).toBe(2); + expect(ctx.plotOptions.series.dashes.show).toBe(true); + }); + }); + + describe('should use timeStep for barWidth', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.bars = true; + ctx.data[0] = new TimeSeries({ + datapoints: [[1, 10], [2, 20]], + alias: 'series1', + }); + }); + }); + + it('should set barWidth', function() { + expect(ctx.plotOptions.series.bars.barWidth).toBe(1 / 1.5); + }); + }); + + describe('series option overrides, fill & points', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.lines = true; + ctrl.panel.fill = 5; + ctx.data[0].zindex = 10; + ctx.data[1].alias = 'test'; + ctx.data[1].lines = { fill: 0.001 }; + ctx.data[1].points = { show: true }; + }); + }); + + it('should match second series and fill zero, and enable points', function() { + expect(ctx.plotOptions.series.lines.fill).toBe(0.5); + expect(ctx.plotData[1].lines.fill).toBe(0.001); + expect(ctx.plotData[1].points.show).toBe(true); + }); + }); + + describe('should order series order according to zindex', () => { + beforeEach(() => { + setupCtx(() => { + ctx.data[1].zindex = 1; + ctx.data[0].zindex = 10; + }); + }); + + it('should move zindex 2 last', function() { + expect(ctx.plotData[0].alias).toBe('series2'); + expect(ctx.plotData[1].alias).toBe('series1'); + }); + }); + + describe('when series is hidden', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.hiddenSeries = { series2: true }; + }); + }); + + it('should remove datapoints and disable stack', function() { + expect(ctx.plotData[0].alias).toBe('series1'); + expect(ctx.plotData[1].data.length).toBe(0); + expect(ctx.plotData[1].stack).toBe(false); + }); + }); + + describe('when stack and percent', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.percentage = true; + ctrl.panel.stack = true; + }); + }); + + it('should show percentage', function() { + var axis = ctx.plotOptions.yaxes[0]; + expect(axis.tickFormatter(100, axis)).toBe('100%'); + }); + }); + + describe('when panel too narrow to show x-axis dates in same granularity as wide panels', () => { + //Set width to 10px + describe('and the range is less than 24 hours', function() { + beforeEach(() => { + setupCtx(() => { + ctrl.range.from = moment([2015, 1, 1, 10]); + ctrl.range.to = moment([2015, 1, 1, 22]); + }); + }); + + it('should format dates as hours minutes', function() { + var axis = ctx.plotOptions.xaxis; + expect(axis.timeformat).toBe('%H:%M'); + }); + }); + + describe('and the range is less than one year', function() { + beforeEach(() => { + setupCtx(() => { + ctrl.range.from = moment([2015, 1, 1]); + ctrl.range.to = moment([2015, 11, 20]); + }); + }); + + it('should format dates as month days', function() { + var axis = ctx.plotOptions.xaxis; + expect(axis.timeformat).toBe('%m/%d'); + }); + }); + }); + + describe('when graph is histogram, and enable stack', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.stack = true; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + ctx.data[1] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series2', + }); + }); + }); + + it('should calculate correct histogram', function() { + expect(ctx.plotData[0].data[0][0]).toBe(100); + expect(ctx.plotData[0].data[0][1]).toBe(2); + expect(ctx.plotData[1].data[0][0]).toBe(100); + expect(ctx.plotData[1].data[0][1]).toBe(2); + }); + }); + + describe('when graph is histogram, and some series are hidden', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.stack = false; + ctrl.hiddenSeries = { series2: true }; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + ctx.data[1] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series2', + }); + }); + }); + + it('should calculate correct histogram', function() { + expect(ctx.plotData[0].data[0][0]).toBe(100); + expect(ctx.plotData[0].data[0][1]).toBe(2); + }); + }); +}); diff --git a/public/app/plugins/panel/graph/specs/graph_specs.ts b/public/app/plugins/panel/graph/specs/graph_specs.ts deleted file mode 100644 index d29320a9d72..00000000000 --- a/public/app/plugins/panel/graph/specs/graph_specs.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { describe, beforeEach, it, sinon, expect, angularMocks } from '../../../../../test/lib/common'; - -import '../module'; -import angular from 'angular'; -import $ from 'jquery'; -import helpers from 'test/specs/helpers'; -import TimeSeries from 'app/core/time_series2'; -import moment from 'moment'; -import { Emitter } from 'app/core/core'; - -describe('grafanaGraph', function() { - beforeEach(angularMocks.module('grafana.core')); - - function graphScenario(desc, func, elementWidth = 500) { - describe(desc, () => { - var ctx: any = {}; - - ctx.setup = setupFunc => { - beforeEach( - angularMocks.module($provide => { - $provide.value('timeSrv', new helpers.TimeSrvStub()); - }) - ); - - beforeEach( - angularMocks.inject(($rootScope, $compile) => { - var ctrl: any = { - height: 200, - panel: { - events: new Emitter(), - legend: {}, - grid: {}, - yaxes: [ - { - min: null, - max: null, - format: 'short', - logBase: 1, - }, - { - min: null, - max: null, - format: 'short', - logBase: 1, - }, - ], - thresholds: [], - xaxis: {}, - seriesOverrides: [], - tooltip: { - shared: true, - }, - }, - renderingCompleted: sinon.spy(), - hiddenSeries: {}, - dashboard: { - getTimezone: sinon.stub().returns('browser'), - }, - range: { - from: moment([2015, 1, 1, 10]), - to: moment([2015, 1, 1, 22]), - }, - }; - - var scope = $rootScope.$new(); - scope.ctrl = ctrl; - scope.ctrl.events = ctrl.panel.events; - - $rootScope.onAppEvent = sinon.spy(); - - ctx.data = []; - ctx.data.push( - new TimeSeries({ - datapoints: [[1, 1], [2, 2]], - alias: 'series1', - }) - ); - ctx.data.push( - new TimeSeries({ - datapoints: [[10, 1], [20, 2]], - alias: 'series2', - }) - ); - - setupFunc(ctrl, ctx.data); - - var element = angular.element("
"); - $compile(element)(scope); - scope.$digest(); - - $.plot = ctx.plotSpy = sinon.spy(); - ctrl.events.emit('render', ctx.data); - ctrl.events.emit('render-legend'); - ctrl.events.emit('legend-rendering-complete'); - ctx.plotData = ctx.plotSpy.getCall(0).args[1]; - ctx.plotOptions = ctx.plotSpy.getCall(0).args[2]; - }) - ); - }; - - func(ctx); - }); - } - - graphScenario('simple lines options', ctx => { - ctx.setup(ctrl => { - ctrl.panel.lines = true; - ctrl.panel.fill = 5; - ctrl.panel.linewidth = 3; - ctrl.panel.steppedLine = true; - }); - - it('should configure plot with correct options', () => { - expect(ctx.plotOptions.series.lines.show).to.be(true); - expect(ctx.plotOptions.series.lines.fill).to.be(0.5); - expect(ctx.plotOptions.series.lines.lineWidth).to.be(3); - expect(ctx.plotOptions.series.lines.steps).to.be(true); - }); - }); - - graphScenario('sorting stacked series as legend. disabled', ctx => { - ctx.setup(ctrl => { - ctrl.panel.legend.sort = undefined; - ctrl.panel.stack = false; - }); - - it('should not modify order of time series', () => { - expect(ctx.plotData[0].alias).to.be('series1'); - expect(ctx.plotData[1].alias).to.be('series2'); - }); - }); - - graphScenario('sorting stacked series as legend. min descending order', ctx => { - ctx.setup(ctrl => { - ctrl.panel.legend.sort = 'min'; - ctrl.panel.legend.sortDesc = true; - ctrl.panel.stack = true; - }); - - it('highest value should be first', () => { - expect(ctx.plotData[0].alias).to.be('series2'); - expect(ctx.plotData[1].alias).to.be('series1'); - }); - }); - - graphScenario('sorting stacked series as legend. min ascending order', ctx => { - ctx.setup((ctrl, data) => { - ctrl.panel.legend.sort = 'min'; - ctrl.panel.legend.sortDesc = false; - ctrl.panel.stack = true; - }); - - it('lowest value should be first', () => { - expect(ctx.plotData[0].alias).to.be('series1'); - expect(ctx.plotData[1].alias).to.be('series2'); - }); - }); - - graphScenario('sorting stacked series as legend. stacking disabled', ctx => { - ctx.setup(ctrl => { - ctrl.panel.legend.sort = 'min'; - ctrl.panel.legend.sortDesc = true; - ctrl.panel.stack = false; - }); - - it('highest value should be first', () => { - expect(ctx.plotData[0].alias).to.be('series1'); - expect(ctx.plotData[1].alias).to.be('series2'); - }); - }); - - graphScenario('sorting stacked series as legend. current descending order', ctx => { - ctx.setup(ctrl => { - ctrl.panel.legend.sort = 'current'; - ctrl.panel.legend.sortDesc = true; - ctrl.panel.stack = true; - }); - - it('highest last value should be first', () => { - expect(ctx.plotData[0].alias).to.be('series2'); - expect(ctx.plotData[1].alias).to.be('series1'); - }); - }); - - graphScenario('when logBase is log 10', function(ctx) { - ctx.setup(function(ctrl, data) { - ctrl.panel.yaxes[0].logBase = 10; - data[0] = new TimeSeries({ - datapoints: [[2000, 1], [0.002, 2], [0, 3], [-1, 4]], - alias: 'seriesAutoscale', - }); - data[0].yaxis = 1; - ctrl.panel.yaxes[1].logBase = 10; - ctrl.panel.yaxes[1].min = '0.05'; - ctrl.panel.yaxes[1].max = '1500'; - data[1] = new TimeSeries({ - datapoints: [[2000, 1], [0.002, 2], [0, 3], [-1, 4]], - alias: 'seriesFixedscale', - }); - data[1].yaxis = 2; - }); - - it('should apply axis transform, autoscaling (if necessary) and ticks', function() { - var axisAutoscale = ctx.plotOptions.yaxes[0]; - expect(axisAutoscale.transform(100)).to.be(2); - expect(axisAutoscale.inverseTransform(-3)).to.within(0.00099999999, 0.00100000001); - expect(axisAutoscale.min).to.within(0.00099999999, 0.00100000001); - expect(axisAutoscale.max).to.be(10000); - expect(axisAutoscale.ticks.length).to.within(7, 8); - expect(axisAutoscale.ticks[0]).to.within(0.00099999999, 0.00100000001); - if (axisAutoscale.ticks.length === 7) { - expect(axisAutoscale.ticks[axisAutoscale.ticks.length - 1]).to.within(999.9999, 1000.0001); - } else { - expect(axisAutoscale.ticks[axisAutoscale.ticks.length - 1]).to.be(10000); - } - - var axisFixedscale = ctx.plotOptions.yaxes[1]; - expect(axisFixedscale.min).to.be(0.05); - expect(axisFixedscale.max).to.be(1500); - expect(axisFixedscale.ticks.length).to.be(5); - expect(axisFixedscale.ticks[0]).to.be(0.1); - expect(axisFixedscale.ticks[4]).to.be(1000); - }); - }); - - graphScenario('when logBase is log 10 and data points contain only zeroes', function(ctx) { - ctx.setup(function(ctrl, data) { - ctrl.panel.yaxes[0].logBase = 10; - data[0] = new TimeSeries({ - datapoints: [[0, 1], [0, 2], [0, 3], [0, 4]], - alias: 'seriesAutoscale', - }); - data[0].yaxis = 1; - }); - - it('should not set min and max and should create some fake ticks', function() { - var axisAutoscale = ctx.plotOptions.yaxes[0]; - expect(axisAutoscale.transform(100)).to.be(2); - expect(axisAutoscale.inverseTransform(-3)).to.within(0.00099999999, 0.00100000001); - expect(axisAutoscale.min).to.be(undefined); - expect(axisAutoscale.max).to.be(undefined); - expect(axisAutoscale.ticks.length).to.be(2); - expect(axisAutoscale.ticks[0]).to.be(1); - expect(axisAutoscale.ticks[1]).to.be(2); - }); - }); - - // y-min set 0 is a special case for log scale, - // this approximates it by setting min to 0.1 - graphScenario('when logBase is log 10 and y-min is set to 0 and auto min is > 0.1', function(ctx) { - ctx.setup(function(ctrl, data) { - ctrl.panel.yaxes[0].logBase = 10; - ctrl.panel.yaxes[0].min = '0'; - data[0] = new TimeSeries({ - datapoints: [[2000, 1], [4, 2], [500, 3], [3000, 4]], - alias: 'seriesAutoscale', - }); - data[0].yaxis = 1; - }); - - it('should set min to 0.1 and add a tick for 0.1', function() { - var axisAutoscale = ctx.plotOptions.yaxes[0]; - expect(axisAutoscale.transform(100)).to.be(2); - expect(axisAutoscale.inverseTransform(-3)).to.within(0.00099999999, 0.00100000001); - expect(axisAutoscale.min).to.be(0.1); - expect(axisAutoscale.max).to.be(10000); - expect(axisAutoscale.ticks.length).to.be(6); - expect(axisAutoscale.ticks[0]).to.be(0.1); - expect(axisAutoscale.ticks[5]).to.be(10000); - }); - }); - - graphScenario('when logBase is log 2 and y-min is set to 0 and num of ticks exceeds max', function(ctx) { - ctx.setup(function(ctrl, data) { - const heightForApprox5Ticks = 125; - ctrl.height = heightForApprox5Ticks; - ctrl.panel.yaxes[0].logBase = 2; - ctrl.panel.yaxes[0].min = '0'; - data[0] = new TimeSeries({ - datapoints: [[2000, 1], [4, 2], [500, 3], [3000, 4], [10000, 5], [100000, 6]], - alias: 'seriesAutoscale', - }); - data[0].yaxis = 1; - }); - - it('should regenerate ticks so that if fits on the y-axis', function() { - var axisAutoscale = ctx.plotOptions.yaxes[0]; - expect(axisAutoscale.min).to.be(0.1); - expect(axisAutoscale.ticks.length).to.be(8); - expect(axisAutoscale.ticks[0]).to.be(0.1); - expect(axisAutoscale.ticks[7]).to.be(262144); - expect(axisAutoscale.max).to.be(262144); - }); - - it('should set axis max to be max tick value', function() { - expect(ctx.plotOptions.yaxes[0].max).to.be(262144); - }); - }); - - graphScenario('dashed lines options', function(ctx) { - ctx.setup(function(ctrl) { - ctrl.panel.lines = true; - ctrl.panel.linewidth = 2; - ctrl.panel.dashes = true; - }); - - it('should configure dashed plot with correct options', function() { - expect(ctx.plotOptions.series.lines.show).to.be(true); - expect(ctx.plotOptions.series.dashes.lineWidth).to.be(2); - expect(ctx.plotOptions.series.dashes.show).to.be(true); - }); - }); - - graphScenario('should use timeStep for barWidth', function(ctx) { - ctx.setup(function(ctrl, data) { - ctrl.panel.bars = true; - data[0] = new TimeSeries({ - datapoints: [[1, 10], [2, 20]], - alias: 'series1', - }); - }); - - it('should set barWidth', function() { - expect(ctx.plotOptions.series.bars.barWidth).to.be(1 / 1.5); - }); - }); - - graphScenario('series option overrides, fill & points', function(ctx) { - ctx.setup(function(ctrl, data) { - ctrl.panel.lines = true; - ctrl.panel.fill = 5; - data[0].zindex = 10; - data[1].alias = 'test'; - data[1].lines = { fill: 0.001 }; - data[1].points = { show: true }; - }); - - it('should match second series and fill zero, and enable points', function() { - expect(ctx.plotOptions.series.lines.fill).to.be(0.5); - expect(ctx.plotData[1].lines.fill).to.be(0.001); - expect(ctx.plotData[1].points.show).to.be(true); - }); - }); - - graphScenario('should order series order according to zindex', function(ctx) { - ctx.setup(function(ctrl, data) { - data[1].zindex = 1; - data[0].zindex = 10; - }); - - it('should move zindex 2 last', function() { - expect(ctx.plotData[0].alias).to.be('series2'); - expect(ctx.plotData[1].alias).to.be('series1'); - }); - }); - - graphScenario('when series is hidden', function(ctx) { - ctx.setup(function(ctrl) { - ctrl.hiddenSeries = { series2: true }; - }); - - it('should remove datapoints and disable stack', function() { - expect(ctx.plotData[0].alias).to.be('series1'); - expect(ctx.plotData[1].data.length).to.be(0); - expect(ctx.plotData[1].stack).to.be(false); - }); - }); - - graphScenario('when stack and percent', function(ctx) { - ctx.setup(function(ctrl) { - ctrl.panel.percentage = true; - ctrl.panel.stack = true; - }); - - it('should show percentage', function() { - var axis = ctx.plotOptions.yaxes[0]; - expect(axis.tickFormatter(100, axis)).to.be('100%'); - }); - }); - - graphScenario( - 'when panel too narrow to show x-axis dates in same granularity as wide panels', - function(ctx) { - describe('and the range is less than 24 hours', function() { - ctx.setup(function(ctrl) { - ctrl.range.from = moment([2015, 1, 1, 10]); - ctrl.range.to = moment([2015, 1, 1, 22]); - }); - - it('should format dates as hours minutes', function() { - var axis = ctx.plotOptions.xaxis; - expect(axis.timeformat).to.be('%H:%M'); - }); - }); - - describe('and the range is less than one year', function() { - ctx.setup(function(scope) { - scope.range.from = moment([2015, 1, 1]); - scope.range.to = moment([2015, 11, 20]); - }); - - it('should format dates as month days', function() { - var axis = ctx.plotOptions.xaxis; - expect(axis.timeformat).to.be('%m/%d'); - }); - }); - }, - 10 - ); - - graphScenario('when graph is histogram, and enable stack', function(ctx) { - ctx.setup(function(ctrl, data) { - ctrl.panel.xaxis.mode = 'histogram'; - ctrl.panel.stack = true; - ctrl.hiddenSeries = {}; - data[0] = new TimeSeries({ - datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], - alias: 'series1', - }); - data[1] = new TimeSeries({ - datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], - alias: 'series2', - }); - }); - - it('should calculate correct histogram', function() { - expect(ctx.plotData[0].data[0][0]).to.be(100); - expect(ctx.plotData[0].data[0][1]).to.be(2); - expect(ctx.plotData[1].data[0][0]).to.be(100); - expect(ctx.plotData[1].data[0][1]).to.be(2); - }); - }); - - graphScenario('when graph is histogram, and some series are hidden', function(ctx) { - ctx.setup(function(ctrl, data) { - ctrl.panel.xaxis.mode = 'histogram'; - ctrl.panel.stack = false; - ctrl.hiddenSeries = { series2: true }; - data[0] = new TimeSeries({ - datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], - alias: 'series1', - }); - data[1] = new TimeSeries({ - datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], - alias: 'series2', - }); - }); - - it('should calculate correct histogram', function() { - expect(ctx.plotData[0].data[0][0]).to.be(100); - expect(ctx.plotData[0].data[0][1]).to.be(2); - }); - }); -});