diff --git a/public/app/core/directives/dropdown_typeahead.js b/public/app/core/directives/dropdown_typeahead.js index c306b9ff8e5..ad484bb18d7 100644 --- a/public/app/core/directives/dropdown_typeahead.js +++ b/public/app/core/directives/dropdown_typeahead.js @@ -45,16 +45,25 @@ function (_, $, coreModule) { } var typeaheadValues = _.reduce($scope.menuItems, function(memo, value, index) { - _.each(value.submenu, function(item, subIndex) { - item.click = 'menuItemSelected(' + index + ',' + subIndex + ')'; - memo.push(value.text + ' ' + item.text); - }); + if (!value.submenu) { + value.click = 'menuItemSelected(' + index + ')'; + memo.push(value.text); + } else { + _.each(value.submenu, function(item, subIndex) { + item.click = 'menuItemSelected(' + index + ',' + subIndex + ')'; + memo.push(value.text + ' ' + item.text); + }); + } return memo; }, []); $scope.menuItemSelected = function(index, subIndex) { - var item = $scope.menuItems[index]; - $scope.dropdownTypeaheadOnSelect({$item: item, $subItem: item.submenu[subIndex]}); + var menuItem = $scope.menuItems[index]; + var payload = {$item: menuItem}; + if (menuItem.submenu && subIndex !== void 0) { + payload.$subItem = menuItem.submenu[subIndex]; + } + $scope.dropdownTypeaheadOnSelect(payload); }; $input.attr('data-provide', 'typeahead'); @@ -65,9 +74,10 @@ function (_, $, coreModule) { updater: function (value) { var result = {}; _.each($scope.menuItems, function(menuItem) { + result.$item = menuItem; + _.each(menuItem.submenu, function(submenuItem) { if (value === (menuItem.text + ' ' + submenuItem.text)) { - result.$item = menuItem; result.$subItem = submenuItem; } }); diff --git a/public/app/core/settings.js b/public/app/core/settings.js index b7fb6e53c14..e452b59b904 100644 --- a/public/app/core/settings.js +++ b/public/app/core/settings.js @@ -10,6 +10,7 @@ function (_) { window_title_prefix : 'Grafana - ', panels : { 'graph': { path: 'app/panels/graph', name: 'Graph' }, + 'table': { path: 'app/panels/table', name: 'Table' }, 'singlestat': { path: 'app/panels/singlestat', name: 'Single stat' }, 'text': { path: 'app/panels/text', name: 'Text' }, 'dashlist': { path: 'app/panels/dashlist', name: 'Dashboard list' }, diff --git a/public/app/core/time_series.js b/public/app/core/time_series.ts similarity index 77% rename from public/app/core/time_series.js rename to public/app/core/time_series.ts index 9465232c200..30086be622a 100644 --- a/public/app/core/time_series.js +++ b/public/app/core/time_series.ts @@ -1,11 +1,46 @@ -define([ - 'lodash', - 'app/core/utils/kbn' -], -function (_, kbn) { - 'use strict'; +/// - function TimeSeries(opts) { +import _ = require('lodash'); +import kbn = require('app/core/utils/kbn'); + +function matchSeriesOverride(aliasOrRegex, seriesAlias) { + if (!aliasOrRegex) { return false; } + + if (aliasOrRegex[0] === '/') { + var regex = kbn.stringToJsRegex(aliasOrRegex); + return seriesAlias.match(regex) != null; + } + + return aliasOrRegex === seriesAlias; +} + +function translateFillOption(fill) { + return fill === 0 ? 0.001 : fill/10; +} + +class TimeSeries { + datapoints: any; + id: string; + label: string; + alias: string; + color: string; + valueFormater: any; + stats: any; + legend: boolean; + allIsNull: boolean; + decimals: number; + scaledDecimals: number; + + lines: any; + bars: any; + points: any; + yaxis: any; + zindex: any; + stack: any; + fillBelowTo: any; + transform: any; + + constructor(opts) { this.datapoints = opts.datapoints; this.label = opts.alias; this.id = opts.alias; @@ -16,22 +51,7 @@ function (_, kbn) { this.legend = true; } - function matchSeriesOverride(aliasOrRegex, seriesAlias) { - if (!aliasOrRegex) { return false; } - - if (aliasOrRegex[0] === '/') { - var regex = kbn.stringToJsRegex(aliasOrRegex); - return seriesAlias.match(regex) != null; - } - - return aliasOrRegex === seriesAlias; - } - - function translateFillOption(fill) { - return fill === 0 ? 0.001 : fill/10; - } - - TimeSeries.prototype.applySeriesOverrides = function(overrides) { + applySeriesOverrides(overrides) { this.lines = {}; this.points = {}; this.bars = {}; @@ -64,7 +84,7 @@ function (_, kbn) { } }; - TimeSeries.prototype.getFlotPairs = function (fillStyle) { + getFlotPairs(fillStyle) { var result = []; this.stats.total = 0; @@ -124,18 +144,17 @@ function (_, kbn) { } return result; - }; + } - TimeSeries.prototype.updateLegendValues = function(formater, decimals, scaledDecimals) { + updateLegendValues(formater, decimals, scaledDecimals) { this.valueFormater = formater; this.decimals = decimals; this.scaledDecimals = scaledDecimals; - }; + } - TimeSeries.prototype.formatValue = function(value) { + formatValue(value) { return this.valueFormater(value, this.decimals, this.scaledDecimals); - }; + } +} - return TimeSeries; - -}); +export = TimeSeries; diff --git a/public/app/core/utils/kbn.js b/public/app/core/utils/kbn.js index ad2d4c3c422..a8d8c8584df 100644 --- a/public/app/core/utils/kbn.js +++ b/public/app/core/utils/kbn.js @@ -192,7 +192,7 @@ function($, _) { kbn.stringToJsRegex = function(str) { if (str[0] !== '/') { - return new RegExp(str); + return new RegExp('^' + str + '$'); } var match = str.match(new RegExp('^/(.*?)/(g?i?m?y?)$')); diff --git a/public/app/features/panel/panel_helper.js b/public/app/features/panel/panel_helper.js index c3ccbfe7e09..52a6727eb2c 100644 --- a/public/app/features/panel/panel_helper.js +++ b/public/app/features/panel/panel_helper.js @@ -32,9 +32,9 @@ function (angular, _, $, kbn, dateMath, rangeUtil) { scope.timing.renderEnd = new Date().getTime(); }; - this.broadcastRender = function(scope, data) { + this.broadcastRender = function(scope, arg1, arg2) { this.setTimeRenderStart(scope); - scope.$broadcast('render', data); + scope.$broadcast('render', arg1, arg2); this.setTimeRenderEnd(scope); if ($rootScope.profilingEnabled) { diff --git a/public/app/panels/graph/module.html b/public/app/panels/graph/module.html index b0793780fa8..9c49c42741d 100644 --- a/public/app/panels/graph/module.html +++ b/public/app/panels/graph/module.html @@ -3,10 +3,6 @@
- - - -
No datapoints No datapoints returned from metric query diff --git a/public/app/panels/graph/styleEditor.html b/public/app/panels/graph/styleEditor.html index 5d5f2fd7401..f692328a0fd 100644 --- a/public/app/panels/graph/styleEditor.html +++ b/public/app/panels/graph/styleEditor.html @@ -63,7 +63,7 @@
Series specific overrides Regex match example: /server[0-3]/i
-
+
  • diff --git a/public/app/panels/table/controller.ts b/public/app/panels/table/controller.ts new file mode 100644 index 00000000000..7c22ba2823f --- /dev/null +++ b/public/app/panels/table/controller.ts @@ -0,0 +1,68 @@ +/// + +import angular = require('angular'); +import _ = require('lodash'); +import moment = require('moment'); +import PanelMeta = require('app/features/panel/panel_meta'); + +import {TableModel} from './table_model'; + +export class TablePanelCtrl { + + constructor($scope, $rootScope, $q, panelSrv, panelHelper) { + $scope.ctrl = this; + $scope.pageIndex = 0; + + $scope.panelMeta = new PanelMeta({ + panelName: 'Table', + editIcon: "fa fa-table", + fullscreen: true, + metricsEditor: true, + }); + + $scope.panelMeta.addEditorTab('Options', 'app/panels/table/options.html'); + $scope.panelMeta.addEditorTab('Time range', 'app/features/panel/partials/panelTime.html'); + + var panelDefaults = { + targets: [{}], + transform: 'timeseries_to_rows', + pageSize: 50, + showHeader: true, + columns: [], + fields: [] + }; + + $scope.init = function() { + _.defaults($scope.panel, panelDefaults); + + if ($scope.panel.columns.length === 0) { + } + + panelSrv.init($scope); + }; + + $scope.refreshData = function(datasource) { + panelHelper.updateTimeRange($scope); + + return panelHelper.issueMetricQuery($scope, datasource) + .then($scope.dataHandler, function(err) { + $scope.seriesList = []; + $scope.render([]); + throw err; + }); + }; + + $scope.dataHandler = function(results) { + $scope.dataRaw = results.data; + $scope.render(); + }; + + $scope.render = function() { + $scope.table = TableModel.transform($scope.dataRaw, $scope.panel); + panelHelper.broadcastRender($scope, $scope.table, $scope.dataRaw); + }; + + $scope.init(); + } +} + diff --git a/public/app/panels/table/editor.html b/public/app/panels/table/editor.html new file mode 100644 index 00000000000..72bc1cf8a1e --- /dev/null +++ b/public/app/panels/table/editor.html @@ -0,0 +1,158 @@ +
    +
    +
    Data
    +
    +
    +
      +
    • + To Table Transform +
    • +
    • + +
    • +
    +
    +
    +
    +
      +
    • + Fields +
    • +
    • + + + {{field.name}} + +
    • + +
    +
    +
    +
    +
    + +
    +
    Table Display
    +
    +
    +
      +
    • + Pagination (Page size) +
    • +
    • + +
    • +
    +
    +
    +
    +
    +
    + +
    +
    Column Styles
    + +
    +
    +
    +
      +
    • + +
    • +
    + +
      +
    • + Name or regex +
    • +
    • + +
    • +
    • + Type +
    • +
    • + +
    • +
    +
      +
    • + Format +
    • +
    • + +
    • +
    +
    +
    +
    +
      +
    • + Coloring +
    • +
    • + +
    • +
    • + ThresholdsComma seperated values +
    • +
    • + +
    • +
    • + Colors +
    • +
    • + + + +
    • +
    • + invert order +
    • +
    +
    +
    +
    +
      +
    • + Unit +
    • + +
    • + Decimals +
    • +
    • + +
    • +
    +
    +
    + +
    +
    + + +
    + diff --git a/public/app/panels/table/editor.ts b/public/app/panels/table/editor.ts new file mode 100644 index 00000000000..8aae1840629 --- /dev/null +++ b/public/app/panels/table/editor.ts @@ -0,0 +1,110 @@ + +/// + +import angular = require('angular'); +import $ = require('jquery'); +import _ = require('lodash'); +import kbn = require('app/core/utils/kbn'); +import moment = require('moment'); + +import {transformers} from './transformers'; + +export function tablePanelEditor() { + 'use strict'; + return { + restrict: 'E', + scope: true, + templateUrl: 'app/panels/table/editor.html', + link: function(scope, elem) { + scope.transformers = transformers; + scope.unitFormats = kbn.getUnitFormats(); + scope.colorModes = [ + {text: 'Disabled', value: null}, + {text: 'Cell', value: 'cell'}, + {text: 'Value', value: 'value'}, + {text: 'Row', value: 'row'}, + ]; + scope.columnTypes = [ + {text: 'Number', value: 'number'}, + {text: 'String', value: 'string'}, + {text: 'Date', value: 'date'}, + ]; + + scope.updateJsonFieldsMenu = function(data) { + scope.jsonFieldsMenu = []; + if (!data || data.length === 0) { + return; + } + + var names = {}; + for (var i = 0; i < data.length; i++) { + var series = data[i]; + if (series.type !== 'docs') { + continue; + } + + for (var y = 0; y < series.datapoints.length; y++) { + var doc = series.datapoints[y]; + for (var propName in doc) { + names[propName] = true; + } + } + } + + _.each(names, function(value, key) { + scope.jsonFieldsMenu.push({text: key}); + }); + }; + + scope.updateJsonFieldsMenu(scope.dataRaw); + + scope.$on('render', function(event, table, rawData) { + scope.updateJsonFieldsMenu(rawData); + }); + + scope.addJsonField = function(menuItem) { + scope.panel.fields.push({name: menuItem.text}); + scope.render(); + }; + + scope.removeJsonField = function(field) { + scope.panel.fields = _.without(scope.panel.fields, field); + scope.render(); + }; + + scope.setUnitFormat = function(column, subItem) { + column.unit = subItem.value; + scope.render(); + }; + + scope.addColumnStyle = function() { + var columnStyleDefaults = { + unit: 'short', + type: 'number', + decimals: 2, + colors: ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], + colorMode: null, + pattern: '/.*/', + thresholds: [], + }; + + scope.panel.columns.push(angular.copy(columnStyleDefaults)); + }; + + scope.removeColumnStyle = function(col) { + scope.panel.columns = _.without(scope.panel.columns, col); + }; + + scope.getColumnNames = function() { + if (!scope.table) { + return []; + } + return _.map(scope.table.columns, function(col: any) { + return col.text; + }); + }; + + } + }; +} + diff --git a/public/app/panels/table/module.html b/public/app/panels/table/module.html new file mode 100644 index 00000000000..1ceb7f52647 --- /dev/null +++ b/public/app/panels/table/module.html @@ -0,0 +1,24 @@ +
    + +
    +
    +
    + + + + + + + + +
    +
    + {{col.text}} +
    +
    +
    +
    + +
    +
    diff --git a/public/app/panels/table/module.ts b/public/app/panels/table/module.ts new file mode 100644 index 00000000000..beb6273fd62 --- /dev/null +++ b/public/app/panels/table/module.ts @@ -0,0 +1,79 @@ +/// + +import angular = require('angular'); +import $ = require('jquery'); +import _ = require('lodash'); +import kbn = require('app/core/utils/kbn'); +import moment = require('moment'); + +import {TablePanelCtrl} from './controller'; +import {TableRenderer} from './renderer'; +import {tablePanelEditor} from './editor'; + +export function tablePanel() { + 'use strict'; + return { + restrict: 'E', + templateUrl: 'app/panels/table/module.html', + controller: TablePanelCtrl, + link: function(scope, elem) { + var data; + var panel = scope.panel; + var formaters = []; + + function getTableHeight() { + var panelHeight = scope.height || scope.panel.height || scope.row.height; + if (_.isString(panelHeight)) { + panelHeight = parseInt(panelHeight.replace('px', ''), 10); + } + + return (panelHeight - 40) + 'px'; + } + + function appendTableRows(tbodyElem) { + var renderer = new TableRenderer(panel, data, scope.dashboard.timezone); + tbodyElem.empty(); + tbodyElem.html(renderer.render(0)); + } + + function appendPaginationControls(footerElem) { + var paginationList = $('
      '); + + var pageCount = data.rows.length / panel.pageSize; + for (var i = 0; i < pageCount; i++) { + var pageLinkElem = $('
    • ' + (i+1) + '
    • '); + paginationList.append(pageLinkElem); + } + + var nextLink = $('
    • ยป
    • '); + paginationList.append(nextLink); + + footerElem.empty(); + footerElem.append(paginationList); + } + + function renderPanel() { + var rootElem = elem.find('.table-panel-scroll'); + var tbodyElem = elem.find('tbody'); + var footerElem = elem.find('.table-panel-footer'); + + appendTableRows(tbodyElem); + + rootElem.css({'max-height': getTableHeight()}); + appendPaginationControls(footerElem); + } + + scope.$on('render', function(event, renderData) { + data = renderData || data; + if (!data) { + scope.get_data(); + return; + } + renderPanel(); + }); + } + }; +} + +angular.module('grafana.directives').directive('grafanaPanelTable', tablePanel); +angular.module('grafana.directives').directive('grafanaPanelTableEditor', tablePanelEditor); diff --git a/public/app/panels/table/options.html b/public/app/panels/table/options.html new file mode 100644 index 00000000000..d43ff958c5d --- /dev/null +++ b/public/app/panels/table/options.html @@ -0,0 +1,2 @@ + + diff --git a/public/app/panels/table/renderer.ts b/public/app/panels/table/renderer.ts new file mode 100644 index 00000000000..acfef961f07 --- /dev/null +++ b/public/app/panels/table/renderer.ts @@ -0,0 +1,129 @@ +/// + +import _ = require('lodash'); +import kbn = require('app/core/utils/kbn'); +import moment = require('moment'); + +export class TableRenderer { + formaters: any[]; + colorState: any; + + constructor(private panel, private table, private timezone) { + this.formaters = []; + this.colorState = {}; + } + + getColorForValue(value, style) { + if (!style.thresholds) { return null; } + + for (var i = style.thresholds.length - 1; i >= 0 ; i--) { + if (value >= style.thresholds[i]) { + return style.colors[i]; + } + } + return null; + } + + createColumnFormater(style) { + if (!style) { + return v => v; + } + + if (style.type === 'date') { + return v => { + if (_.isArray(v)) { v = v[0]; } + var date = moment(v); + if (this.timezone === 'utc') { + date = date.utc(); + } + return date.format(style.dateFormat); + }; + } + + if (style.type === 'number') { + let valueFormater = kbn.valueFormats[style.unit]; + + return v => { + if (v === null || v === void 0) { + return '-'; + } + + if (_.isString(v)) { + return v; + } + + if (style.colorMode) { + this.colorState[style.colorMode] = this.getColorForValue(v, style); + } + + return valueFormater(v, style.decimals, null); + }; + } + + return v => { + if (v === null || v === void 0) { + return '-'; + } + + if (_.isArray(v)) { + v = v.join(', '); + } + + return v; + }; + } + + formatColumnValue(colIndex, value) { + if (this.formaters[colIndex]) { + return this.formaters[colIndex](value); + } + + for (let i = 0; i < this.panel.columns.length; i++) { + let style = this.panel.columns[i]; + let column = this.table.columns[colIndex]; + var regex = kbn.stringToJsRegex(style.pattern); + if (column.text.match(regex)) { + this.formaters[colIndex] = this.createColumnFormater(style); + return this.formaters[colIndex](value); + } + } + + this.formaters[colIndex] = function(v) { + return v; + }; + + return this.formaters[colIndex](value); + } + + renderCell(columnIndex, value) { + var value = this.formatColumnValue(columnIndex, value); + var style = ''; + if (this.colorState.cell) { + style = ' style="background-color:' + this.colorState.cell + ';color: white"'; + this.colorState.cell = null; + } + else if (this.colorState.value) { + style = ' style="color:' + this.colorState.value + '"'; + this.colorState.value = null; + } + + return '' + value + ''; + } + + render(page) { + let endPos = Math.min(this.panel.pageSize, this.table.rows.length); + let startPos = 0; + var html = ""; + + for (var y = startPos; y < endPos; y++) { + let row = this.table.rows[y]; + html += ''; + for (var i = 0; i < this.table.columns.length; i++) { + html += this.renderCell(i, row[i]); + } + html += ''; + } + + return html; + } +} diff --git a/public/app/panels/table/specs/renderer_specs.ts b/public/app/panels/table/specs/renderer_specs.ts new file mode 100644 index 00000000000..fad4f993acf --- /dev/null +++ b/public/app/panels/table/specs/renderer_specs.ts @@ -0,0 +1,65 @@ +import {describe, beforeEach, it, sinon, expect} from 'test/lib/common'; + +import {TableModel} from '../table_model'; +import {TableRenderer} from '../renderer'; + +describe('when rendering table', () => { + describe('given 2 columns', () => { + var table = new TableModel(); + table.columns = [ + {text: 'Time'}, + {text: 'Value'}, + {text: 'Colored'} + ]; + + var panel = { + pageSize: 10, + columns: [ + { + pattern: 'Time', + type: 'date', + format: 'LLL' + }, + { + pattern: 'Value', + type: 'number', + unit: 'ms', + decimals: 3, + }, + { + pattern: 'Colored', + type: 'number', + unit: 'none', + decimals: 1, + colorMode: 'value', + thresholds: [0, 50, 80], + colors: ['green', 'orange', 'red'] + } + ] + }; + + var renderer = new TableRenderer(panel, table, 'utc'); + + it('time column should be formated', () => { + var html = renderer.renderCell(0, 1388556366666); + expect(html).to.be('2014-01-01T06:06:06+00:00'); + }); + + it('number column should be formated', () => { + var html = renderer.renderCell(1, 1230); + expect(html).to.be('1.230 s'); + }); + + it('number style should ignore string values', () => { + var html = renderer.renderCell(1, 'asd'); + expect(html).to.be('asd'); + }); + + it('colored cell should have style', () => { + var html = renderer.renderCell(2, 55); + expect(html).to.be('55.0'); + }); + }); +}); + + diff --git a/public/app/panels/table/specs/transformers_specs.ts b/public/app/panels/table/specs/transformers_specs.ts new file mode 100644 index 00000000000..597b8913b82 --- /dev/null +++ b/public/app/panels/table/specs/transformers_specs.ts @@ -0,0 +1,107 @@ +import {describe, beforeEach, it, sinon, expect} from 'test/lib/common'; + +import {TableModel} from '../table_model'; + +describe('when transforming time series table', () => { + var table; + + describe('given 2 time series', () => { + var time = new Date().getTime(); + var timeSeries = [ + { + target: 'series1', + datapoints: [[12.12, time], [14.44, time+1]], + }, + { + target: 'series2', + datapoints: [[16.12, time]], + } + ]; + + describe('timeseries_to_rows', () => { + var panel = {transform: 'timeseries_to_rows'}; + + beforeEach(() => { + table = TableModel.transform(timeSeries, panel); + }); + + it('should return 3 rows', () => { + expect(table.rows.length).to.be(3); + expect(table.rows[0][1]).to.be('series1'); + expect(table.rows[1][1]).to.be('series1'); + expect(table.rows[2][1]).to.be('series2'); + expect(table.rows[0][2]).to.be(12.12); + }); + + it('should return 3 rows', () => { + expect(table.columns.length).to.be(3); + expect(table.columns[0].text).to.be('Time'); + expect(table.columns[1].text).to.be('Series'); + expect(table.columns[2].text).to.be('Value'); + }); + }); + + describe('timeseries_to_columns', () => { + var panel = { + transform: 'timeseries_to_columns' + }; + + beforeEach(() => { + table = TableModel.transform(timeSeries, panel); + }); + + it ('should return 3 columns', () => { + expect(table.columns.length).to.be(3); + expect(table.columns[0].text).to.be('Time'); + expect(table.columns[1].text).to.be('series1'); + expect(table.columns[2].text).to.be('series2'); + }); + + it ('should return 2 rows', () => { + expect(table.rows.length).to.be(2); + expect(table.rows[0][1]).to.be(12.12); + expect(table.rows[0][2]).to.be(16.12); + }); + + it ('should be undefined when no value for timestamp', () => { + expect(table.rows[1][2]).to.be(undefined); + }); + }); + + describe('JSON Data', () => { + var panel = { + transform: 'json', + fields: [{name: 'timestamp'}, {name: 'message'}] + }; + var rawData = [ + { + type: 'docs', + datapoints: [ + { + timestamp: 'time', + message: 'message' + } + ] + } + ]; + + beforeEach(() => { + table = TableModel.transform(rawData, panel); + }); + + it ('should return 2 columns', () => { + expect(table.columns.length).to.be(2); + expect(table.columns[0].text).to.be('timestamp'); + expect(table.columns[1].text).to.be('message'); + }); + + it ('should return 2 rows', () => { + expect(table.rows.length).to.be(1); + expect(table.rows[0][0]).to.be('time'); + expect(table.rows[0][1]).to.be('message'); + }); + + }); + }); +}); + diff --git a/public/app/panels/table/table_model.ts b/public/app/panels/table/table_model.ts new file mode 100644 index 00000000000..943234bcd89 --- /dev/null +++ b/public/app/panels/table/table_model.ts @@ -0,0 +1,27 @@ +import {transformers} from './transformers'; + +export class TableModel { + columns: any[]; + rows: any[]; + + constructor() { + this.columns = []; + this.rows = []; + } + + static transform(data, panel) { + var model = new TableModel(); + + if (!data || data.length === 0) { + return model; + } + + var transformer = transformers[panel.transform]; + if (!transformer) { + throw {message: 'Transformer ' + panel.transformer + ' not found'}; + } + + transformer.transform(data, panel, model); + return model; + } +} diff --git a/public/app/panels/table/transformers.ts b/public/app/panels/table/transformers.ts new file mode 100644 index 00000000000..dce0098dde3 --- /dev/null +++ b/public/app/panels/table/transformers.ts @@ -0,0 +1,102 @@ +/// + +import moment = require('moment'); +import _ = require('lodash'); + +var transformers = {}; + +transformers['timeseries_to_rows'] = { + description: 'Time series to rows', + transform: function(data, panel, model) { + model.columns = [ + {text: 'Time', type: 'date'}, + {text: 'Series'}, + {text: 'Value'}, + ]; + + for (var i = 0; i < data.length; i++) { + var series = data[i]; + for (var y = 0; y < series.datapoints.length; y++) { + var dp = series.datapoints[y]; + model.rows.push([dp[1], series.target, dp[0]]); + } + } + }, +}; + +transformers['timeseries_to_columns'] = { + description: 'Time series to columns', + transform: function(data, panel, model) { + model.columns.push({text: 'Time', type: 'date'}); + + // group by time + var points = {}; + + for (var i = 0; i < data.length; i++) { + var series = data[i]; + model.columns.push({text: series.target}); + + for (var y = 0; y < series.datapoints.length; y++) { + var dp = series.datapoints[y]; + var timeKey = dp[1].toString(); + + if (!points[timeKey]) { + points[timeKey] = {time: dp[1]}; + points[timeKey][i] = dp[0]; + } + else { + points[timeKey][i] = dp[0]; + } + } + } + + for (var time in points) { + var point = points[time]; + var values = [point.time]; + + for (var i = 0; i < data.length; i++) { + var value = point[i]; + values.push(value); + } + + model.rows.push(values); + } + } +}; + +transformers['annotations'] = { + description: 'Annotations', +}; + +transformers['json'] = { + description: 'JSON Data', + transform: function(data, panel, model) { + var i, y, z; + for (i = 0; i < panel.fields.length; i++) { + model.columns.push({text: panel.fields[i].name}); + } + + if (model.columns.length === 0) { + model.columns.push({text: 'JSON'}); + } + + for (i = 0; i < data.length; i++) { + var series = data[i]; + + for (y = 0; y < series.datapoints.length; y++) { + var dp = series.datapoints[y]; + var values = []; + for (z = 0; z < panel.fields.length; z++) { + values.push(dp[panel.fields[z].name]); + } + + if (values.length === 0) { + values.push(JSON.stringify(dp)); + } + model.rows.push(values); + } + } + } +}; + +export {transformers} diff --git a/public/app/plugins/datasource/elasticsearch/datasource.js b/public/app/plugins/datasource/elasticsearch/datasource.js index 4e27da73850..ce370c639d2 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.js +++ b/public/app/plugins/datasource/elasticsearch/datasource.js @@ -153,8 +153,8 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes }); }; - ElasticDatasource.prototype.getQueryHeader = function(timeFrom, timeTo) { - var header = {search_type: "count", "ignore_unavailable": true}; + ElasticDatasource.prototype.getQueryHeader = function(searchType, timeFrom, timeTo) { + var header = {search_type: searchType, "ignore_unavailable": true}; header.index = this.indexPattern.getIndexList(timeFrom, timeTo); return angular.toJson(header); }; @@ -163,20 +163,27 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes var payload = ""; var target; var sentTargets = []; - - var header = this.getQueryHeader(options.range.from, options.range.to); + var headerAdded = false; for (var i = 0; i < options.targets.length; i++) { target = options.targets[i]; if (target.hide) {return;} - var esQuery = angular.toJson(this.queryBuilder.build(target)); + var queryObj = this.queryBuilder.build(target); + var esQuery = angular.toJson(queryObj); var luceneQuery = angular.toJson(target.query || '*'); // remove inner quotes luceneQuery = luceneQuery.substr(1, luceneQuery.length - 2); esQuery = esQuery.replace("$lucene_query", luceneQuery); - payload += header + '\n' + esQuery + '\n'; + if (!headerAdded) { + var searchType = queryObj.size === 0 ? 'count' : 'query_then_fetch'; + var header = this.getQueryHeader(searchType, options.range.from, options.range.to); + payload += header + '\n'; + headerAdded = true; + } + + payload += esQuery + '\n'; sentTargets.push(target); } @@ -185,7 +192,7 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes payload = payload.replace(/\$timeTo/g, options.range.to.valueOf()); payload = templateSrv.replace(payload, options.scopedVars); - return this._post('/_msearch?search_type=count', payload).then(function(res) { + return this._post('/_msearch', payload).then(function(res) { return new ElasticResponse(sentTargets, res).getTimeSeries(); }); }; @@ -229,7 +236,7 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes ElasticDatasource.prototype.getTerms = function(queryDef) { var range = timeSrv.timeRange(); - var header = this.getQueryHeader(range.from, range.to); + var header = this.getQueryHeader('count', range.from, range.to); var esQuery = angular.toJson(this.queryBuilder.getTermsQuery(queryDef)); esQuery = esQuery.replace("$lucene_query", queryDef.query || '*'); diff --git a/public/app/plugins/datasource/elasticsearch/elastic_response.js b/public/app/plugins/datasource/elasticsearch/elastic_response.js index bfbcb2034ab..aaac2e46ea1 100644 --- a/public/app/plugins/datasource/elasticsearch/elastic_response.js +++ b/public/app/plugins/datasource/elasticsearch/elastic_response.js @@ -173,6 +173,33 @@ function (_, queryDef) { } }; + ElasticResponse.prototype.processHits = function(hits, seriesList) { + var series = {target: 'docs', type: 'docs', datapoints: [], total: hits.total}; + var propName, hit, doc, i; + + for (i = 0; i < hits.hits.length; i++) { + hit = hits.hits[i]; + doc = { + _id: hit._id, + _type: hit._type, + _index: hit._index + }; + + if (hit._source) { + for (propName in hit._source) { + doc[propName] = hit._source[propName]; + } + } + + for (propName in hit.fields) { + doc[propName] = hit.fields[propName]; + } + series.datapoints.push(doc); + } + + seriesList.push(series); + }; + ElasticResponse.prototype.getTimeSeries = function() { var seriesList = []; @@ -182,15 +209,21 @@ function (_, queryDef) { throw { message: response.error }; } - var aggregations = response.aggregations; - var target = this.targets[i]; - var tmpSeriesList = []; + if (response.hits) { + this.processHits(response.hits, seriesList); + } - this.processBuckets(aggregations, target, tmpSeriesList, {}); - this.nameSeries(tmpSeriesList, target); + if (response.aggregations) { + var aggregations = response.aggregations; + var target = this.targets[i]; + var tmpSeriesList = []; - for (var y = 0; y < tmpSeriesList.length; y++) { - seriesList.push(tmpSeriesList[y]); + this.processBuckets(aggregations, target, tmpSeriesList, {}); + this.nameSeries(tmpSeriesList, target); + + for (var y = 0; y < tmpSeriesList.length; y++) { + seriesList.push(tmpSeriesList[y]); + } } } diff --git a/public/app/plugins/datasource/elasticsearch/metric_agg.js b/public/app/plugins/datasource/elasticsearch/metric_agg.js index fd748a1f8b0..c4e04dad325 100644 --- a/public/app/plugins/datasource/elasticsearch/metric_agg.js +++ b/public/app/plugins/datasource/elasticsearch/metric_agg.js @@ -28,6 +28,7 @@ function (angular, _, queryDef) { $scope.isFirst = $scope.index === 0; $scope.isSingle = metricAggs.length === 1; $scope.settingsLinkText = ''; + $scope.aggDef = _.findWhere($scope.metricAggTypes, {value: $scope.agg.type}); if (!$scope.agg.field) { $scope.agg.field = 'select field'; @@ -53,6 +54,11 @@ function (angular, _, queryDef) { $scope.agg.meta.std_deviation_bounds_lower = true; $scope.agg.meta.std_deviation_bounds_upper = true; } + break; + } + case 'raw_document': { + $scope.target.metrics = [$scope.agg]; + $scope.target.bucketAggs = []; } } }; @@ -65,8 +71,6 @@ function (angular, _, queryDef) { $scope.agg.settings = {}; $scope.agg.meta = {}; $scope.showOptions = false; - - $scope.validateModel(); $scope.onChange(); }; diff --git a/public/app/plugins/datasource/elasticsearch/partials/metricAgg.html b/public/app/plugins/datasource/elasticsearch/partials/metricAgg.html index 7d58be38094..65030af5655 100644 --- a/public/app/plugins/datasource/elasticsearch/partials/metricAgg.html +++ b/public/app/plugins/datasource/elasticsearch/partials/metricAgg.html @@ -6,7 +6,7 @@
    • -
    • +
    • diff --git a/public/app/plugins/datasource/elasticsearch/query_builder.js b/public/app/plugins/datasource/elasticsearch/query_builder.js index d0fbb6a603a..3a956012a86 100644 --- a/public/app/plugins/datasource/elasticsearch/query_builder.js +++ b/public/app/plugins/datasource/elasticsearch/query_builder.js @@ -71,6 +71,16 @@ function (angular) { return filterObj; }; + ElasticQueryBuilder.prototype.documentQuery = function(query) { + query.size = 500; + query.sort = {}; + query.sort[this.timeField] = {order: 'desc', unmapped_type: 'boolean'}; + query.fields = ["*", "_source"]; + query.script_fields = {}, + query.fielddata_fields = [this.timeField]; + return query; + }; + ElasticQueryBuilder.prototype.build = function(target) { if (target.rawQuery) { return angular.fromJson(target.rawQuery); @@ -96,6 +106,15 @@ function (angular) { } }; + // handle document query + if (target.bucketAggs.length === 0) { + metric = target.metrics[0]; + if (metric && metric.type !== 'raw_document') { + throw {message: 'Invalid query'}; + } + return this.documentQuery(query, target); + } + nestedAggs = query; for (i = 0; i < target.bucketAggs.length; i++) { diff --git a/public/app/plugins/datasource/elasticsearch/query_def.js b/public/app/plugins/datasource/elasticsearch/query_def.js index ca915c68dd4..baab2378e9f 100644 --- a/public/app/plugins/datasource/elasticsearch/query_def.js +++ b/public/app/plugins/datasource/elasticsearch/query_def.js @@ -6,14 +6,15 @@ function (_) { return { metricAggTypes: [ - {text: "Count", value: 'count' }, - {text: "Average", value: 'avg' }, - {text: "Sum", value: 'sum' }, - {text: "Max", value: 'max' }, - {text: "Min", value: 'min' }, - {text: "Extended Stats", value: 'extended_stats' }, - {text: "Percentiles", value: 'percentiles' }, - {text: "Unique Count", value: "cardinality" } + {text: "Count", value: 'count', requiresField: false}, + {text: "Average", value: 'avg', requiresField: true}, + {text: "Sum", value: 'sum', requiresField: true}, + {text: "Max", value: 'max', requiresField: true}, + {text: "Min", value: 'min', requiresField: true}, + {text: "Extended Stats", value: 'extended_stats', requiresField: true}, + {text: "Percentiles", value: 'percentiles', requiresField: true}, + {text: "Unique Count", value: "cardinality", requiresField: true}, + {text: "Raw Document", value: "raw_document", requiresField: false} ], bucketAggTypes: [ diff --git a/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts b/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts index f087f847a19..1e5d39ba817 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts @@ -80,4 +80,36 @@ describe('ElasticDatasource', function() { expect(body.query.filtered.query.query_string.query).to.be('escape\\:test'); }); }); + + describe('When issueing document query', function() { + var requestOptions, parts, header; + + beforeEach(function() { + ctx.ds = new ctx.service({url: 'http://es.com', index: 'test', jsonData: {}}); + + ctx.backendSrv.datasourceRequest = function(options) { + requestOptions = options; + return ctx.$q.when({data: {responses: []}}); + }; + + ctx.ds.query({ + range: { from: moment([2015, 4, 30, 10]), to: moment([2015, 5, 1, 10]) }, + targets: [{ bucketAggs: [], metrics: [{type: 'raw_document'}], query: 'test' }] + }); + + ctx.$rootScope.$apply(); + parts = requestOptions.data.split('\n'); + header = angular.fromJson(parts[0]); + }); + + it('should set search type to query_then_fetch', function() { + expect(header.search_type).to.eql('query_then_fetch'); + }); + + it('should set size', function() { + var body = angular.fromJson(parts[1]); + expect(body.size).to.be(500); + }); + }); + }); diff --git a/public/app/plugins/datasource/elasticsearch/specs/elastic_response_specs.ts b/public/app/plugins/datasource/elasticsearch/specs/elastic_response_specs.ts index df810e3a9d9..5ae67f92d78 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/elastic_response_specs.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/elastic_response_specs.ts @@ -411,4 +411,40 @@ describe('ElasticResponse', function() { }); }); + describe('Raw documents query', function() { + beforeEach(function() { + targets = [{ refId: 'A', metrics: [{type: 'raw_document', id: '1'}], bucketAggs: [] }]; + response = { + responses: [{ + hits: { + total: 100, + hits: [ + { + _id: '1', + _type: 'type', + _index: 'index', + _source: {sourceProp: "asd"}, + fields: {fieldProp: "field" }, + }, + { + _source: {sourceProp: "asd2"}, + fields: {fieldProp: "field2" }, + } + ] + } + }] + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return docs', function() { + expect(result.data.length).to.be(1); + expect(result.data[0].type).to.be('docs'); + expect(result.data[0].total).to.be(100); + expect(result.data[0].datapoints.length).to.be(2); + expect(result.data[0].datapoints[0].sourceProp).to.be("asd"); + expect(result.data[0].datapoints[0].fieldProp).to.be("field"); + }); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts b/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts index c32b1463ca3..bcae7d6e852 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts @@ -120,4 +120,14 @@ describe('ElasticQueryBuilder', function() { expect(query.aggs["2"].aggs["4"].date_histogram.field).to.be("@timestamp"); }); + it('with raw_document metric', function() { + var query = builder.build({ + metrics: [{type: 'raw_document', id: '1'}], + timeField: '@timestamp', + bucketAggs: [], + }); + + expect(query.size).to.be(500); + }); + }); diff --git a/public/app/plugins/datasource/influxdb/partials/config.html b/public/app/plugins/datasource/influxdb/partials/config.html index 66c39fe7b69..4d51a33e6b2 100644 --- a/public/app/plugins/datasource/influxdb/partials/config.html +++ b/public/app/plugins/datasource/influxdb/partials/config.html @@ -1,4 +1,3 @@ -

      diff --git a/public/less/grafana.less b/public/less/grafana.less index 6e23faa214d..041c0112481 100644 --- a/public/less/grafana.less +++ b/public/less/grafana.less @@ -1,19 +1,21 @@ @import "type.less"; @import "login.less"; @import "submenu.less"; -@import "graph.less"; +@import "panel_graph.less"; +@import "panel_dashlist.less"; +@import "panel_singlestat.less"; +@import "panel_table.less"; @import "bootstrap-tagsinput.less"; @import "tables_lists.less"; @import "search.less"; @import "panel.less"; @import "forms.less"; -@import "singlestat.less"; @import "tightform.less"; @import "sidemenu.less"; @import "navbar.less"; @import "gfbox.less"; -@import "dashlist.less"; @import "admin.less"; +@import "pagination.less"; @import "validation.less"; @import "fonts.less"; @import "tabs.less"; diff --git a/public/less/pagination.less b/public/less/pagination.less new file mode 100644 index 00000000000..6e292e5ab37 --- /dev/null +++ b/public/less/pagination.less @@ -0,0 +1,113 @@ +.pagination { +} + +.pagination ul { + display: inline-block; + margin-left: 0; + margin-bottom: 0; + .border-radius(@baseBorderRadius); + .box-shadow(0 1px 2px rgba(0,0,0,.05)); +} +.pagination ul > li { + display: inline; // Remove list-style and block-level defaults +} +.pagination ul > li > a, +.pagination ul > li > span { + float: left; // Collapse white-space + padding: 4px 12px; + line-height: @baseLineHeight; + text-decoration: none; + background-color: @paginationBackground; + border: 1px solid @paginationBorder; + border-left-width: 0; +} +.pagination ul > li > a:hover, +.pagination ul > li > a:focus, +.pagination ul > .active > a, +.pagination ul > .active > span { + background-color: @paginationActiveBackground; +} +.pagination ul > .active > a, +.pagination ul > .active > span { + color: @grayLight; + cursor: default; +} +.pagination ul > .disabled > span, +.pagination ul > .disabled > a, +.pagination ul > .disabled > a:hover, +.pagination ul > .disabled > a:focus { + color: @grayLight; + background-color: transparent; + cursor: default; +} +.pagination ul > li:first-child > a, +.pagination ul > li:first-child > span { + border-left-width: 1px; + .border-left-radius(@baseBorderRadius); +} +.pagination ul > li:last-child > a, +.pagination ul > li:last-child > span { + .border-right-radius(@baseBorderRadius); +} + + +// Alignment +// -------------------------------------------------- + +.pagination-centered { + text-align: center; +} +.pagination-right { + text-align: right; +} + + +// Sizing +// -------------------------------------------------- + +// Large +.pagination-large { + ul > li > a, + ul > li > span { + padding: @paddingLarge; + font-size: @fontSizeLarge; + } + ul > li:first-child > a, + ul > li:first-child > span { + .border-left-radius(@borderRadiusLarge); + } + ul > li:last-child > a, + ul > li:last-child > span { + .border-right-radius(@borderRadiusLarge); + } +} + +// Small and mini +.pagination-mini, +.pagination-small { + ul > li:first-child > a, + ul > li:first-child > span { + .border-left-radius(@borderRadiusSmall); + } + ul > li:last-child > a, + ul > li:last-child > span { + .border-right-radius(@borderRadiusSmall); + } +} + +// Small +.pagination-small { + ul > li > a, + ul > li > span { + padding: @paddingSmall; + font-size: @fontSizeSmall; + } +} +// Mini +.pagination-mini { + ul > li > a, + ul > li > span { + padding: @paddingMini; + font-size: @fontSizeMini; + } +} diff --git a/public/less/dashlist.less b/public/less/panel_dashlist.less similarity index 100% rename from public/less/dashlist.less rename to public/less/panel_dashlist.less diff --git a/public/less/graph.less b/public/less/panel_graph.less similarity index 100% rename from public/less/graph.less rename to public/less/panel_graph.less diff --git a/public/less/singlestat.less b/public/less/panel_singlestat.less similarity index 100% rename from public/less/singlestat.less rename to public/less/panel_singlestat.less diff --git a/public/less/panel_table.less b/public/less/panel_table.less new file mode 100644 index 00000000000..58fb6376f5c --- /dev/null +++ b/public/less/panel_table.less @@ -0,0 +1,88 @@ +.table-panel-wrapper { + .panel-content { + padding: 0; + } + .panel-title-container { + padding-bottom: 4px; + } +} + +.table-panel-scroll { + overflow: auto; +} + +.table-panel-container { + padding-top: 32px; + position: relative; +} + +.table-panel-footer { + text-align: center; + font-size: 80%; + line-height: 2px; + + ul { + position: relative; + display: inline-block; + margin-left: 0; + margin-bottom: 0; + } + ul > li { + display: inline; // Remove list-style and block-level defaults + } + ul > li > a { + float: left; // Collapse white-space + padding: 4px 12px; + text-decoration: none; + border-left-width: 0; + } +} + +.table-panel-table { + width: 100%; + border-collapse: collapse; + + tr { + border-bottom: 2px solid @bodyBackground; + } + + th { + padding: 0; + + &:first-child { + .table-panel-table-header-inner { + padding-left: 15px; + } + } + } + + td { + padding: 10px 0 10px 15px; + + &:first-child { + padding-left: 15px; + } + } +} + +.table-panel-header-bg { + background: @grafanaListAccent; + border-top: 2px solid @bodyBackground; + border-bottom: 2px solid @bodyBackground; + height: 30px; + position: absolute; + top: 0; + right: 0; + left: 0; +} + +.table-panel-table-header-inner { + padding: 5px 0 5px 15px; + text-align: left; + color: @blue; + + position: absolute; + top: 0; + line-height: 23px; +} +