/** * Copyright (c) Richard Wall * See LICENSE for details. * * Wrappers and convenience fuctions for working with the javascriptRRD, jQuery, * and Flot charting packages. * * Designed to work well with the RRD files generated by Collectd: * - http://collectd.org/ * * Requirements: * - javascriptRRD/rrdFile 0.6/1.1: http://javascriptrrd.sourceforge.net/docs/javascriptrrd_v1.1.1/doc/lib/rrdFile_js.html * - javascriptRRD/binaryXHR 0.6/1.1: http://javascriptrrd.sourceforge.net/docs/javascriptrrd_v1.1.1/doc/lib/binaryXHR_js.html * - jQuery 1.6.3: http://jquery.com/ * - Flot 1.7: http://www.flotcharts.org/ * - jQueryTools/tabs: http://jquerytools.github.io/documentation/tabs/index.html * - jQueryTools/toolbox/history: http://jquerytools.github.io/documentation/toolbox/history.html */ /** * A namespace for Jarmon * * @namespace jarmon */ if(typeof(jarmon) === 'undefined') { var jarmon = {}; } /** * An RrdDataSource is an object that provides a getData method that retrieves * the data for a single Data Source. * * @interface jarmon.RrdDataSource */ /** * @function * @name jarmon.RrdDataSource#getData * @param startTime {number} The start timestamp * @param endTime {number} The end timestamp * @return {module:jQuery.Deferred} A Deferred which calls back with a Flot data * series. */ /** * @callback jarmon.RrdQueryRemote.Downloader * @param url {string} The url of the object to be downloaded * @return {module:jQuery.Deferred} A Deferred which will callback with an * instance of {@link module:javascriptRRD/binaryXHR.BinaryFile} */ /** * Download a binary file asynchronously using the {@link jQuery.ajax} * function. * * @member * @type jarmon.RrdQueryRemote.Downloader * * @requires jQuery * @requires javascriptRRD/binaryXHR */ jarmon.downloadBinary = function(url) { var d = jQuery.Deferred(); $.ajax({ url: url, dataType: 'text', cache: false, mimeType: 'text/plain; charset=x-user-defined', xhr: function() { // Save a reference to the native xhr object - we need it later // in IE to access the binary data from responseBody this._nativeXhr = jQuery.ajaxSettings.xhr(); return this._nativeXhr; }, complete: function(jqXHR, textStatus) { this._nativeXhr = null; delete this._nativeXhr; }, success: function(data, textStatus, jqXHR) { var response = this._nativeXhr.responseBody // for IE if (response==undefined) { response=this._nativeXhr.responseText // for standards-compliant browsers } d.resolve(new BinaryFile(response)); }, error: function(xhr, textStatus, errorThrown) { d.reject(new Error(textStatus + ':' + xhr.status)); } }); return d; }; /** * Copied from jquery.flot.js and modified to allow timezone * adjustment. * * @method * @requires Flot * * @param v {number} The timestamp to be formatted * @param axis {Object} A hash containing information about the time axis * @return {string} The formatted datetime string **/ jarmon.localTimeFormatter = function (v, axis) { // map of app. size of time units in milliseconds var timeUnitSize = { "second": 1000, "minute": 60 * 1000, "hour": 60 * 60 * 1000, "day": 24 * 60 * 60 * 1000, "month": 30 * 24 * 60 * 60 * 1000, "year": 365.2425 * 24 * 60 * 60 * 1000 }; // Offset the input timestamp by the user defined amount var d = new Date(v + axis.options.tzoffset); // first check global format if (axis.options.timeformat !== null) { return $.plot.formatDate( d, axis.options.timeformat, axis.options.monthNames); } var t = axis.tickSize[0] * timeUnitSize[axis.tickSize[1]]; var span = axis.max - axis.min; var suffix = (axis.options.twelveHourClock) ? " %p" : ""; var fmt; if (t < timeUnitSize.minute) fmt = "%h:%M:%S" + suffix; else if (t < timeUnitSize.day) { if (span < 2 * timeUnitSize.day) fmt = "%h:%M" + suffix; else fmt = "%b %d %h:%M" + suffix; } else if (t < timeUnitSize.month) fmt = "%b %d"; else if (t < timeUnitSize.year) { if (span < timeUnitSize.year) fmt = "%b"; else fmt = "%b %y"; } else fmt = "%y"; return $.plot.formatDate(d, fmt, axis.options.monthNames); }; /** * A wrapper around an instance of {@link module:javascriptRRD/rrdFile.RRDFile} which * provides a convenient way to query the RRDFile based on time range, RRD data * source (DS) and RRD consolidation function (CF). * * @constructor * @param rrd {module:javascriptRRD/rrdFile.RRDFile} * @param unit {string} The unit symbol for this data series **/ jarmon.RrdQuery = function(rrd, unit) { this.rrd = rrd; this.unit = unit; }; /** * Generate a Flot compatible data object containing rows between start and end * time. The rows are taken from the first RRA whose data spans the requested * time range. * * @method * @param startTimeJs {number} start timestamp in microseconds * @param endTimeJs {number} end timestamp in microseconds * @param [dsId=0] {(string|number)} identifier of the RRD datasource * @param [cfName="AVERAGE"] {string} The name of an RRD consolidation function * (CF) eg AVERAGE, MIN, MAX * @param {function} [transformer=function(v){return v}] A callable which * performs a transformation of the values returned from the RRD file. * @return {module:Flot.data} A Flot-compatible data series * eg `{label: '', data: [], unit: ''}` **/ jarmon.RrdQuery.prototype.getData = function(startTimeJs, endTimeJs, dsId, cfName, transformer) { if (startTimeJs >= endTimeJs) { throw RangeError( ['starttime must be less than endtime.', 'starttime:', startTimeJs, 'endtime:', endTimeJs].join(' ')); } var startTime = startTimeJs/1000; var lastUpdated = this.rrd.getLastUpdate(); // default endTime to the last updated time (quantized to rrd step boundry) var endTime = lastUpdated - lastUpdated%this.rrd.getMinStep(); if(endTimeJs) { endTime = endTimeJs/1000; } if(typeof(dsId) === 'undefined' && dsId !== null) { dsId = 0; } var ds = this.rrd.getDS(dsId); if(typeof(cfName) === 'undefined' && cfName !== null) { cfName = 'AVERAGE'; } if(typeof(transformer) === 'undefined') { transformer = function(v) {return v;}; } var rra, step, rraRowCount, lastRowTime, firstRowTime; for(var i=0; i} An array of DS names. **/ jarmon.RrdQuery.prototype.getDSNames = function() { return this.rrd.getDSNames(); }; /** * A wrapper around {@link jarmon.RrdQuery} which provides asynchronous access * to the data in a remote RRD file. * * @constructor * @requires javascriptRRD/rrdFile * @implements {jarmon.RrdDataSource} * * @param url {string} The url of a remote RRD file * @param unit {string} The unit suffix of this data eg 'bit/sec' * @param [downloader=jarmon.downloadBinary] {jarmon.RrdQueryRemote.Downloader} **/ jarmon.RrdQueryRemote = function(url, unit, downloader) { this.url = url; this.unit = unit; if(typeof(downloader) == 'undefined') { this.downloader = jarmon.downloadBinary; } else { this.downloader = downloader; } this.lastUpdate = -Infinity; this._download = null; }; /** * Asynchronously call a method on the underlying {@link jarmon.RrdQuery}. Think of it * as an async .apply(). * * @method * @private * @param methodName {string} * @param args {Array} * @return {module:jQuery.Deferred} A Deferred that calls .methodName(args...) on the * underlying {@link jarmon.RrdQuery}. */ jarmon.RrdQueryRemote.prototype._callRemote = function(methodName, args) { // Download the rrd if there has never been a download and don't start // another download if one is already in progress. var self = this; if(!this._download) { this._download = this.downloader(this.url); } // Set up a deferred which will call getData on the local RrdQuery object // returning a Flot compatible data object to the caller. var ret = jQuery.Deferred(); // Add a pair of callbacks to the current download which will callback the // result which we setup above. this._download.always( function(res) { if(res instanceof Error) { //ret.reject(res); result = { data: [ [args[0], 0], [args[1], 0] ], lines: { lineWidth: 0 } }; ret.resolve(result); return result; } else { // Upon successful download convert the resulting binary // into an RRD file var rrd = new RRDFile(res); self.lastUpdate = rrd.getLastUpdate(); var rq = new jarmon.RrdQuery(rrd, self.unit); try { ret.resolve(rq[methodName].apply(rq, args)); } catch(e) { ret.reject(e); } } return res; }); return ret; }; /** * Return a Flot compatible data series asynchronously. * * @method * @param startTime {number} The start timestamp * @param endTime {number} The end timestamp * @param [dsId=0] {(string|number)} identifier of the RRD datasource (string or number) * @param [cfName="AVERAGE"] {string} The name of an RRD consolidation function * (CF) eg AVERAGE, MIN, MAX * @param {function} [transformer=function(v){return v}] A callable which * performs a tranfsformation of the values returned from the RRD file. * @return {module:jQuery.Deferred} A Deferred which calls back with a Flot data * series. **/ jarmon.RrdQueryRemote.prototype.getData = function(startTime, endTime, dsId, cfName, transformer) { if(this.lastUpdate < endTime/1000) { this._download = null; } return this._callRemote('getData', [startTime, endTime, dsId, cfName, transformer]); }; /** * Return a list of RRD Data Source names * * @method * @return {module:jQuery.Deferred} A Deferred which calls back with an array of * DS names. **/ jarmon.RrdQueryRemote.prototype.getDSNames = function() { return this._callRemote('getDSNames'); }; /** * Wraps RrdQueryRemote to provide access to a different RRD DSs within a * single {@link jarmon.RrdQueryRemote}. * * @constructor * @implements {jarmon.RrdDataSource} * * @param rrdQuery {jarmon.RrdQueryRemote} An RrdQueryRemote instance * @param dsId {(string|number)} identifier of the RRD datasource (string or number) * @param {function} [transformer=function(v){return v}] A callable which * performs a transformation of the values returned from the RRD file. **/ jarmon.RrdQueryDsProxy = function(rrdQuery, dsId, transformer) { this.rrdQuery = rrdQuery; this.dsId = dsId; this.unit = rrdQuery.unit; this.transformer = transformer; }; /** * Call {@link jarmon.RrdQueryRemote#getData} with a particular dsId * * @method * @param startTime {number} A unix timestamp marking the start time * @param endTime {number} A unix timestamp marking the start time * @return {module:jQuery.Deferred} A Deferred which calls back with a Flot data * series. **/ jarmon.RrdQueryDsProxy.prototype.getData = function(startTime, endTime) { return this.rrdQuery.getData(startTime, endTime, this.dsId, undefined, this.transformer); }; /** * A class for creating a Flot chart from a series of RRD Queries * * The `template` parameter should be a jQuery for a DOM node containing * elements matching these selectors: * * - `.title` : Placeholder for the title * - `.chart` : Placeholder for the chart itself * - `.graph-legend` : Placeholder for the chart legend * - `.error` : Placeholder for any error message (will be hidden on success) * * @constructor * @requires jQuery * @requires Flot * * @param template {module:jQuery.jQuery} A one-element jQuery containing * placeholders for chart elements. * @param recipe {Object} * @param recipe.title {string} * @param recipe.data {Array} * @param recipe.options {module:Flot.options} Flot options which control how * the chart should be drawn. **/ jarmon.Chart = function(template, recipe, downloader) { this.template = template; this.recipe = recipe; this.downloader = downloader; this.options = jQuery.extend(true, {yaxis: {}}, recipe.options); this.data = []; this.setup(); var self = this; // Listen for clicks on the legend items - onclick enable / disable the // corresponding data source. $('.graph-legend .legendItem', this.template[0]).live('click', function(e) { self.switchDataEnabled($(this).text()); self.draw(); }); /* @todo The user should be able to override this with `ticks`, `tickSize`, * or `minTickSize`. * @todo Look in to implementing this as a tickFormatter, and letting Flot * take care of scaling. */ this.options.yaxis.ticks = function(axis) { /* * Choose a suitable SI multiplier based on the min and max values from * the axis and then generate appropriate yaxis tick labels. * * @param axis {Object} An Object with min and max properties. * @return {Array.} An array of ~5 tick labels. */ var siPrefixes = { 0: '', 1: 'K', 2: 'M', 3: 'G', 4: 'T' }; var si = 0; while(true) { if( Math.pow(1000, si+1)*0.9 > (axis.max - axis.min) ) { break; } si++; } var minVal = axis.min/Math.pow(1000, si); var maxVal = axis.max/Math.pow(1000, si); var stepSizes = [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 25, 50, 100, 250]; var realStep = (maxVal - minVal)/5.0; var stepSize, decimalPlaces = 0; for(var i=0; i') .text(self.siPrefix + unit) .addClass('yaxisUnitLabel'); self.template.find('.chart').append(yaxisUnitLabel); // Manipulate and move the Flot generated legend to an // alternative position. // The default legend is formatted as an HTML table, so we // grab the contents of the cells and turn them into // divs. // Actually, formatting the legend first as a one column // table is useful as it generates an optimum label element // width which we can copy to the new divs + a little extra // to accomodate the color box // // @todo This seems super hacky // @todo Look in to implementing this using Flot // `.legend.container`. var legend = self.template.find('.graph-legend').show(); legend.empty(); self.template.find('.legendLabel').each( function(i, el) { var orig = $(el); var label = orig.text(); var newEl = $('
', { 'class': 'legendItem', 'title': ('Data series switch - ' + 'click to turn this data series on or off') }) .width(orig.width()+20) // @todo Make this styleable .text(label) .prepend( orig.prev() .find('div div') .clone().addClass('legendColorBox')) .appendTo(legend); // The legend label is clickable - to enable / // disable different data series. The disabled class // results in a label formatted with strike though if( $.inArray(label, disabled) > -1 ) { newEl.addClass('disabled'); } } ).remove(); legend.append($('
').css('clear', 'both')); self.template.find('.legend').remove(); yaxisUnitLabel.position(self.template.position()); return data; }, null) .fail( function(failure) { self.template.find('.chart').empty().hide(); self.template.find('.graph-legend').empty().hide(); self.template.find('.error').text( 'error: ' + failure.message); }) .always( function(res) { self.template.removeClass('loading'); return res; }); }; /** * Generate a form through which to choose a data source from a remote RRD file * * @todo Create an example that uses this. * * @constructor * @requires jQuery * * @param $tpl {module:jQuery.jQuery} jQuery object for the DOM node to attach to. **/ jarmon.RrdChooser = function($tpl) { this.$tpl = $tpl; this.data = { rrdUrl: '', dsName: '', dsLabel: '', dsUnit:'' }; }; /** * @method */ jarmon.RrdChooser.prototype.drawRrdUrlForm = function() { var self = this; this.$tpl.empty(); $('
').append( $('
').append( $('

').text('Enter the URL of an RRD file'), $('

', {'class': 'next'}) ) ).submit( function(e) { self.data.rrdUrl = this.rrd_url.value; var $placeholder = $(this).find('.next').empty(); new jarmon.RrdQueryRemote( self.data.rrdUrl).getDSNames().addCallback( function($placeholder, dsNames) { if(dsNames.length > 1) { $('

').text( 'The RRD file contains multiple data sources. ' + 'Choose one:').appendTo($placeholder); $(dsNames).map( function(i, el) { return $('', { type: 'button', value: el } ).click( function(e) { self.data.dsName = this.value; self.drawDsLabelForm(); } ); }).appendTo($placeholder); } else { self.data.dsName = dsNames[0]; self.drawDsLabelForm(); } }, $placeholder ).addErrback( function($placeholder, err) { $('

', {'class': 'error'}) .text(err.toString()).appendTo($placeholder); }, $placeholder ); return false; } ).appendTo(this.$tpl); }; /** * @method */ jarmon.RrdChooser.prototype.drawDsLabelForm = function() { var self = this; this.$tpl.empty(); $('').append( $('

').text('Choose a label and unit for this data source.'), $('

').append( $('