diff --git a/src/app/components/kbn.js b/src/app/components/kbn.js
index 9cd4ed83382..14cca2f4e33 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 00000000000..65fc07eb7f5
--- /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 00000000000..673371fcf4c
--- /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 00000000000..e34701cabb8
--- /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 00000000000..63bf8318fb2
--- /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 00000000000..8885e734371
--- /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 ae544de0ba0..80c0c8129a7 100644
--- a/src/config.js
+++ b/src/config.js
@@ -48,7 +48,8 @@ function (Settings) {
'trends',
'bettermap',
'query',
- 'terms'
+ 'terms',
+ 'sparklines'
]
});
});