From 41046796c80ac22c3b969169a317ffdb3ca7581d Mon Sep 17 00:00:00 2001 From: Rashid Khan Date: Thu, 31 Oct 2013 08:22:53 -0700 Subject: [PATCH] Added sparklines panel --- src/app/components/kbn.js | 2 +- src/app/panels/sparklines/editor.html | 23 ++ src/app/panels/sparklines/interval.js | 57 ++++ src/app/panels/sparklines/module.html | 10 + src/app/panels/sparklines/module.js | 379 ++++++++++++++++++++++++ src/app/panels/sparklines/timeSeries.js | 216 ++++++++++++++ src/config.js | 3 +- 7 files changed, 688 insertions(+), 2 deletions(-) create mode 100644 src/app/panels/sparklines/editor.html create mode 100644 src/app/panels/sparklines/interval.js create mode 100644 src/app/panels/sparklines/module.html create mode 100644 src/app/panels/sparklines/module.js create mode 100644 src/app/panels/sparklines/timeSeries.js diff --git a/src/app/components/kbn.js b/src/app/components/kbn.js index 9cd4ed83382a..14cca2f4e33f 100644 --- a/src/app/components/kbn.js +++ b/src/app/components/kbn.js @@ -465,7 +465,7 @@ function($, _, moment) { kbn.colorSteps = function(col,steps) { - var _d = steps > 5 ? 1.6/steps : 0.3, // distance between steps + var _d = steps > 5 ? 1.6/steps : 0.25, // distance between steps _p = []; // adjustment percentage // Create a range of numbers between -0.8 and 0.8 diff --git a/src/app/panels/sparklines/editor.html b/src/app/panels/sparklines/editor.html new file mode 100644 index 000000000000..65fc07eb7f54 --- /dev/null +++ b/src/app/panels/sparklines/editor.html @@ -0,0 +1,23 @@ +
+
+
Values
+
+ + +
+
+ + +
+
+ + +
+
+
+
Transform Series
+
+ +
+
+
\ No newline at end of file diff --git a/src/app/panels/sparklines/interval.js b/src/app/panels/sparklines/interval.js new file mode 100644 index 000000000000..673371fcf4ca --- /dev/null +++ b/src/app/panels/sparklines/interval.js @@ -0,0 +1,57 @@ +define([ + 'kbn' +], +function (kbn) { + 'use strict'; + + /** + * manages the interval logic + * @param {[type]} interval_string An interval string in the format '1m', '1y', etc + */ + function Interval(interval_string) { + this.string = interval_string; + + var info = kbn.describe_interval(interval_string); + this.type = info.type; + this.ms = info.sec * 1000 * info.count; + + // does the length of the interval change based on the current time? + if (this.type === 'y' || this.type === 'M') { + // we will just modify this time object rather that create a new one constantly + this.get = this.get_complex; + this.date = new Date(0); + } else { + this.get = this.get_simple; + } + } + + Interval.prototype = { + toString: function () { + return this.string; + }, + after: function(current_ms) { + return this.get(current_ms, 1); + }, + before: function (current_ms) { + return this.get(current_ms, -1); + }, + get_complex: function (current, delta) { + this.date.setTime(current); + switch(this.type) { + case 'M': + this.date.setUTCMonth(this.date.getUTCMonth() + delta); + break; + case 'y': + this.date.setUTCFullYear(this.date.getUTCFullYear() + delta); + break; + } + return this.date.getTime(); + }, + get_simple: function (current, delta) { + return current + (delta * this.ms); + } + }; + + return Interval; + +}); \ No newline at end of file diff --git a/src/app/panels/sparklines/module.html b/src/app/panels/sparklines/module.html new file mode 100644 index 000000000000..e34701cabb81 --- /dev/null +++ b/src/app/panels/sparklines/module.html @@ -0,0 +1,10 @@ +
+
+ + +
+ {{series.info.alias}}
+
+
+ +
\ No newline at end of file diff --git a/src/app/panels/sparklines/module.js b/src/app/panels/sparklines/module.js new file mode 100644 index 000000000000..63bf8318fb2d --- /dev/null +++ b/src/app/panels/sparklines/module.js @@ -0,0 +1,379 @@ +/* + + ## Histogram + + ### Parameters + * auto_int :: Auto calculate data point interval? + * resolution :: If auto_int is enables, shoot for this many data points, rounding to + sane intervals + * interval :: Datapoint interval in elasticsearch date math format (eg 1d, 1w, 1y, 5y) + * fill :: Only applies to line charts. Level of area shading from 0-10 + * linewidth :: Only applies to line charts. How thick the line should be in pixels + While the editor only exposes 0-10, this can be any numeric value. + Set to 0 and you'll get something like a scatter plot + * timezone :: This isn't totally functional yet. Currently only supports browser and utc. + browser will adjust the x-axis labels to match the timezone of the user's + browser + * spyable :: Dislay the 'eye' icon that show the last elasticsearch query + * zoomlinks :: Show the zoom links? + * bars :: Show bars in the chart + * stack :: Stack multiple queries. This generally a crappy way to represent things. + You probably should just use a line chart without stacking + * points :: Should circles at the data points on the chart + * lines :: Line chart? Sweet. + * legend :: Show the legend? + * x-axis :: Show x-axis labels and grid lines + * y-axis :: Show y-axis labels and grid lines + * interactive :: Allow drag to select time range + +*/ +define([ + 'angular', + 'app', + 'jquery', + 'underscore', + 'kbn', + 'moment', + './timeSeries', + + 'jquery.flot', + 'jquery.flot.time' +], +function (angular, app, $, _, kbn, moment, timeSeries) { + + 'use strict'; + + var module = angular.module('kibana.panels.sparklines', []); + app.useModule(module); + + module.controller('sparklines', function($scope, querySrv, dashboard, filterSrv) { + $scope.panelMeta = { + modals : [ + { + description: "Inspect", + icon: "icon-info-sign", + partial: "app/partials/inspector.html", + show: $scope.panel.spyable + } + ], + editorTabs : [ + { + title:'Queries', + src:'app/partials/querySelect.html' + } + ], + status : "Experimental", + description : "Sparklines are tiny, simple, time series charts, shown seperately. Because "+ + "sparklines are unclutted by grids, axis markers and colors, they are perfect for spotting"+ + " change in a series" + }; + + // Set and populate defaults + var _d = { + mode : 'count', + time_field : '@timestamp', + queries : { + mode : 'all', + ids : [] + }, + value_field : null, + interval : '5m', + spyable : true + }; + + _.defaults($scope.panel,_d); + + $scope.init = function() { + + $scope.$on('refresh',function(){ + $scope.get_data(); + }); + + $scope.get_data(); + + }; + + $scope.interval_label = function(interval) { + return $scope.panel.auto_int && interval === $scope.panel.interval ? interval+" (auto)" : interval; + }; + + /** + * The time range effecting the panel + * @return {[type]} [description] + */ + $scope.get_time_range = function () { + var range = $scope.range = filterSrv.timeRange('last'); + return range; + }; + + $scope.get_interval = function () { + var interval = $scope.panel.interval, + range; + range = $scope.get_time_range(); + if (range) { + interval = kbn.secondsToHms( + kbn.calculate_interval(range.from, range.to, 10, 0) / 1000 + ); + } + $scope.panel.interval = interval || '10m'; + return $scope.panel.interval; + }; + + /** + * Fetch the data for a chunk of a queries results. Multiple segments occur when several indicies + * need to be consulted (like timestamped logstash indicies) + * + * The results of this function are stored on the scope's data property. This property will be an + * array of objects with the properties info, time_series, and hits. These objects are used in the + * render_panel function to create the historgram. + * + * @param {number} segment The segment count, (0 based) + * @param {number} query_id The id of the query, generated on the first run and passed back when + * this call is made recursively for more segments + */ + $scope.get_data = function(segment, query_id) { + var + _range, + _interval, + request, + queries, + results; + + if (_.isUndefined(segment)) { + segment = 0; + } + delete $scope.panel.error; + + // Make sure we have everything for the request to complete + if(dashboard.indices.length === 0) { + return; + } + _range = $scope.get_time_range(); + _interval = $scope.get_interval(_range); + + $scope.panelMeta.loading = true; + request = $scope.ejs.Request().indices(dashboard.indices[segment]); + + $scope.panel.queries.ids = querySrv.idsByMode($scope.panel.queries); + + queries = querySrv.getQueryObjs($scope.panel.queries.ids); + + // Build the query + _.each(queries, function(q) { + var query = $scope.ejs.FilteredQuery( + querySrv.toEjsObj(q), + filterSrv.getBoolFilter(filterSrv.ids) + ); + + var facet = $scope.ejs.DateHistogramFacet(q.id); + + if($scope.panel.mode === 'count') { + facet = facet.field($scope.panel.time_field).global(true); + } else { + if(_.isNull($scope.panel.value_field)) { + $scope.panel.error = "In " + $scope.panel.mode + " mode a field must be specified"; + return; + } + facet = facet.keyField($scope.panel.time_field).valueField($scope.panel.value_field); + } + facet = facet.interval(_interval).facetFilter($scope.ejs.QueryFilter(query)); + request = request.facet(facet) + .size(0); + }); + + // Populate the inspector panel + $scope.populate_modal(request); + + // Then run it + results = request.doSearch(); + + // Populate scope when we have results + results.then(function(results) { + + $scope.panelMeta.loading = false; + if(segment === 0) { + $scope.hits = 0; + $scope.data = []; + query_id = $scope.query_id = new Date().getTime(); + } + + // Check for error and abort if found + if(!(_.isUndefined(results.error))) { + $scope.panel.error = $scope.parse_error(results.error); + return; + } + + // Make sure we're still on the same query/queries + if($scope.query_id === query_id) { + + var i = 0, + time_series, + hits; + + _.each(queries, function(q) { + var query_results = results.facets[q.id]; + // we need to initialize the data variable on the first run, + // and when we are working on the first segment of the data. + if(_.isUndefined($scope.data[i]) || segment === 0) { + var tsOpts = { + interval: _interval, + start_date: _range && _range.from, + end_date: _range && _range.to, + fill_style: 'minimal' + }; + time_series = new timeSeries.ZeroFilled(tsOpts); + hits = 0; + } else { + time_series = $scope.data[i].time_series; + hits = $scope.data[i].hits; + } + + // push each entry into the time series, while incrementing counters + _.each(query_results.entries, function(entry) { + time_series.addValue(entry.time, entry[$scope.panel.mode]); + hits += entry.count; // The series level hits counter + $scope.hits += entry.count; // Entire dataset level hits counter + }); + $scope.data[i] = { + info: q, + range: $scope.range, + time_series: time_series, + hits: hits + }; + + i++; + }); + + // If we still have segments left, get them + if(segment < dashboard.indices.length-1) { + $scope.get_data(segment+1,query_id); + } + } + }); + }; + + // I really don't like this function, too much dom manip. Break out into directive? + $scope.populate_modal = function(request) { + $scope.inspector = angular.toJson(JSON.parse(request.toString()),true); + }; + + $scope.set_refresh = function (state) { + $scope.refresh = state; + }; + + $scope.close_edit = function() { + if($scope.refresh) { + $scope.get_data(); + } + $scope.refresh = false; + }; + + }); + + module.directive('sparklinesChart', function() { + return { + restrict: 'A', + scope: { + series: '=', + panel: '=' + }, + template: '
', + link: function(scope, elem) { + + // Receive render events + scope.$watch('series',function(){ + render_panel(); + }); + + // Re-render if the window is resized + angular.element(window).bind('resize', function(){ + render_panel(); + }); + + var derivative = function(series) { + return _.map(series, function(p,i) { + var _v; + if(i === 0 || p[1] === null) { + _v = [p[0],null]; + } else { + _v = series[i-1][1] === null ? [p[0],null] : [p[0],p[1]-(series[i-1][1])]; + } + return _v; + }); + }; + + // Function for rendering panel + function render_panel() { + // IE doesn't work without this + elem.css({height:"30px",width:"100px"}); + + // Populate element + //try { + var options = { + legend: { show: false }, + series: { + lines: { + show: true, + // Silly, but fixes bug in stacked percentages + fill: 0, + lineWidth: 2, + steps: false + }, + points: { radius:2 }, + shadowSize: 1 + }, + yaxis: { + show: false + }, + xaxis: { + show: false, + mode: "time", + min: _.isUndefined(scope.series.range.from) ? null : scope.series.range.from.getTime(), + max: _.isUndefined(scope.series.range.to) ? null : scope.series.range.to.getTime() + }, + grid: { + hoverable: true, + show: false + } + }; + // when rendering stacked bars, we need to ensure each point that has data is zero-filled + // so that the stacking happens in the proper order + var required_times = []; + required_times = scope.series.time_series.getOrderedTimes(); + required_times = _.uniq(required_times.sort(function (a, b) { + // decending numeric sort + return a-b; + }), true); + + var _d = { + data : scope.panel.derivative ? + derivative(scope.series.time_series.getFlotPairs(required_times)) : + scope.series.time_series.getFlotPairs(required_times), + label : scope.series.info.alias, + color : elem.css('color'), + }; + + $.plot(elem, [_d], options); + + //} catch(e) { + // console.log(e); + //} + } + + var $tooltip = $('
'); + elem.bind("plothover", function (event, pos, item) { + if (item) { + $tooltip + .html( + item.datapoint[1] + " @ " + moment(item.datapoint[0]).format('YYYY-MM-DD HH:mm:ss') + ) + .place_tt(pos.pageX, pos.pageY); + } else { + $tooltip.detach(); + } + }); + } + }; + }); + +}); diff --git a/src/app/panels/sparklines/timeSeries.js b/src/app/panels/sparklines/timeSeries.js new file mode 100644 index 000000000000..8885e734371d --- /dev/null +++ b/src/app/panels/sparklines/timeSeries.js @@ -0,0 +1,216 @@ +define([ + 'underscore', + './interval' +], +function (_, Interval) { + 'use strict'; + + var ts = {}; + + // map compatable parseInt + function base10Int(val) { + return parseInt(val, 10); + } + + // trim the ms off of a time, but return it with empty ms. + function getDatesTime(date) { + return Math.floor(date.getTime() / 1000)*1000; + } + + /** + * Certain graphs require 0 entries to be specified for them to render + * properly (like the line graph). So with this we will caluclate all of + * the expected time measurements, and fill the missing ones in with 0 + * @param {object} opts An object specifying some/all of the options + * + * OPTIONS: + * @opt {string} interval The interval notion describing the expected spacing between + * each data point. + * @opt {date} start_date (optional) The start point for the time series, setting this and the + * end_date will ensure that the series streches to resemble the entire + * expected result + * @opt {date} end_date (optional) The end point for the time series, see start_date + * @opt {string} fill_style Either "minimal", or "all" describing the strategy used to zero-fill + * the series. + */ + ts.ZeroFilled = function (opts) { + opts = _.defaults(opts, { + interval: '10m', + start_date: null, + end_date: null, + fill_style: 'minimal' + }); + + // the expected differenece between readings. + this.interval = new Interval(opts.interval); + + // will keep all values here, keyed by their time + this._data = {}; + this.start_time = opts.start_date && getDatesTime(opts.start_date); + this.end_time = opts.end_date && getDatesTime(opts.end_date); + this.opts = opts; + }; + + /** + * Add a row + * @param {int} time The time for the value, in + * @param {any} value The value at this time + */ + ts.ZeroFilled.prototype.addValue = function (time, value) { + if (time instanceof Date) { + time = getDatesTime(time); + } else { + time = base10Int(time); + } + if (!isNaN(time)) { + this._data[time] = (_.isUndefined(value) ? 0 : value); + } + this._cached_times = null; + }; + + /** + * Get an array of the times that have been explicitly set in the series + * @param {array} include (optional) list of timestamps to include in the response + * @return {array} An array of integer times. + */ + ts.ZeroFilled.prototype.getOrderedTimes = function (include) { + var times = _.map(_.keys(this._data), base10Int); + if (_.isArray(include)) { + times = times.concat(include); + } + return _.uniq(times.sort(function (a, b) { + // decending numeric sort + return a - b; + }), true); + }; + + /** + * return the rows in the format: + * [ [time, value], [time, value], ... ] + * + * Heavy lifting is done by _get(Min|Default|All)FlotPairs() + * @param {array} required_times An array of timestamps that must be in the resulting pairs + * @return {array} + */ + ts.ZeroFilled.prototype.getFlotPairs = function (required_times) { + var times = this.getOrderedTimes(required_times), + strategy, + pairs; + + if(this.opts.fill_style === 'all') { + strategy = this._getAllFlotPairs; + } else if(this.opts.fill_style === 'null') { + strategy = this._getNullFlotPairs; + } else { + strategy = this._getMinFlotPairs; + } + + pairs = _.reduce( + times, // what + strategy, // how + [], // where + this // context + ); + + // if the first or last pair is inside either the start or end time, + // add those times to the series with null values so the graph will stretch to contain them. + // Removing, flot 0.8.1's max/min params satisfy this + /* + if (this.start_time && (pairs.length === 0 || pairs[0][0] > this.start_time)) { + pairs.unshift([this.start_time, null]); + } + if (this.end_time && (pairs.length === 0 || pairs[pairs.length - 1][0] < this.end_time)) { + pairs.push([this.end_time, null]); + } + */ + + return pairs; + }; + + /** + * ** called as a reduce stragegy in getFlotPairs() ** + * Fill zero's on either side of the current time, unless there is already a measurement there or + * we are looking at an edge. + * @return {array} An array of points to plot with flot + */ + ts.ZeroFilled.prototype._getMinFlotPairs = function (result, time, i, times) { + var next, expected_next, prev, expected_prev; + + // check for previous measurement + if (i > 0) { + prev = times[i - 1]; + expected_prev = this.interval.before(time); + if (prev < expected_prev) { + result.push([expected_prev, 0]); + } + } + + // add the current time + result.push([ time, this._data[time] || 0]); + + // check for next measurement + if (times.length > i) { + next = times[i + 1]; + expected_next = this.interval.after(time); + if (next > expected_next) { + result.push([expected_next, 0]); + } + } + + return result; + }; + + /** + * ** called as a reduce stragegy in getFlotPairs() ** + * Fill zero's to the right of each time, until the next measurement is reached or we are at the + * last measurement + * @return {array} An array of points to plot with flot + */ + ts.ZeroFilled.prototype._getAllFlotPairs = function (result, time, i, times) { + var next, expected_next; + + result.push([ times[i], this._data[times[i]] || 0 ]); + next = times[i + 1]; + expected_next = this.interval.after(time); + for(; times.length > i && next > expected_next; expected_next = this.interval.after(expected_next)) { + result.push([expected_next, 0]); + } + + return result; + }; + + /** + * ** called as a reduce stragegy in getFlotPairs() ** + * Same as min, but fills with nulls + * @return {array} An array of points to plot with flot + */ + ts.ZeroFilled.prototype._getNullFlotPairs = function (result, time, i, times) { + var next, expected_next, prev, expected_prev; + + // check for previous measurement + if (i > 0) { + prev = times[i - 1]; + expected_prev = this.interval.before(time); + if (prev < expected_prev) { + result.push([expected_prev, null]); + } + } + + // add the current time + result.push([ time, this._data[time] || null]); + + // check for next measurement + if (times.length > i) { + next = times[i + 1]; + expected_next = this.interval.after(time); + if (next > expected_next) { + result.push([expected_next, null]); + } + } + + return result; + }; + + + return ts; +}); \ No newline at end of file diff --git a/src/config.js b/src/config.js index ae544de0ba05..80c0c8129a72 100644 --- a/src/config.js +++ b/src/config.js @@ -48,7 +48,8 @@ function (Settings) { 'trends', 'bettermap', 'query', - 'terms' + 'terms', + 'sparklines' ] }); });