diff --git a/web/package.json b/web/package.json index 1025e28e..3a36bd56 100644 --- a/web/package.json +++ b/web/package.json @@ -4,6 +4,7 @@ "axios-mock-adapter": "^1.14.1", "babel-core": "~6.24.0", "babel-loader": "~7.1.2", + "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-preset-airbnb": "^2.4.0", "babel-preset-es2015": "~6.24.0", "babel-preset-react": "~6.23.0", @@ -65,7 +66,7 @@ "dropzone": "^5.1.1", "eonasdan-bootstrap-datetimepicker": "^4.17.47", "exports-loader": "~0.6.4", - "flotr2": "^0.1.0", + "flotr2": "git+https://github.com/EnterpriseDB/Flotr2.git", "font-awesome": "^4.7.0", "hard-source-webpack-plugin": "^0.4.9", "immutability-helper": "^2.2.0", diff --git a/web/pgadmin/browser/static/js/panel.js b/web/pgadmin/browser/static/js/panel.js index 5238dcee..dbd2788f 100644 --- a/web/pgadmin/browser/static/js/panel.js +++ b/web/pgadmin/browser/static/js/panel.js @@ -165,7 +165,8 @@ define( if (eventName == 'panelClosed') { pgBrowser.save_current_layout(pgBrowser); - pgAdmin.Dashboard.toggleVisibility(false); + /* Pass the closed flag also */ + pgAdmin.Dashboard.toggleVisibility(false, true); } else if (eventName == 'panelVisibilityChanged') { if (pgBrowser.tree) { pgBrowser.save_current_layout(pgBrowser); @@ -174,8 +175,10 @@ define( pgAdmin.Dashboard.toggleVisibility(pgBrowser.panels.dashboard.panel.isVisible()); } // Explicitly trigger tree selected event when we add the tab. - pgBrowser.Events.trigger('pgadmin-browser:tree:selected', selectedNode, - pgBrowser.tree.itemData(selectedNode), pgBrowser.Node); + if(selectedNode.length) { + pgBrowser.Events.trigger('pgadmin-browser:tree:selected', selectedNode, + pgBrowser.tree.itemData(selectedNode), pgBrowser.Node); + } } } }, diff --git a/web/pgadmin/dashboard/__init__.py b/web/pgadmin/dashboard/__init__.py index ffd4a7bc..677dc18f 100644 --- a/web/pgadmin/dashboard/__init__.py +++ b/web/pgadmin/dashboard/__init__.py @@ -83,7 +83,7 @@ class DashboardModule(PgAdminModule): help_str=gettext('The number of seconds between graph samples.') ) - self.session_stats_refresh = self.dashboard_preference.register( + self.tps_stats_refresh = self.dashboard_preference.register( 'dashboards', 'tps_stats_refresh', gettext("Transaction throughput refresh rate"), 'integer', 1, min_val=1, max_val=999999, @@ -91,7 +91,7 @@ class DashboardModule(PgAdminModule): help_str=gettext('The number of seconds between graph samples.') ) - self.session_stats_refresh = self.dashboard_preference.register( + self.ti_stats_refresh = self.dashboard_preference.register( 'dashboards', 'ti_stats_refresh', gettext("Tuples in refresh rate"), 'integer', 1, min_val=1, max_val=999999, @@ -99,7 +99,7 @@ class DashboardModule(PgAdminModule): help_str=gettext('The number of seconds between graph samples.') ) - self.session_stats_refresh = self.dashboard_preference.register( + self.to_stats_refresh = self.dashboard_preference.register( 'dashboards', 'to_stats_refresh', gettext("Tuples out refresh rate"), 'integer', 1, min_val=1, max_val=999999, @@ -107,7 +107,7 @@ class DashboardModule(PgAdminModule): help_str=gettext('The number of seconds between graph samples.') ) - self.session_stats_refresh = self.dashboard_preference.register( + self.bio_stats_refresh = self.dashboard_preference.register( 'dashboards', 'bio_stats_refresh', gettext("Block I/O statistics refresh rate"), 'integer', 1, min_val=1, max_val=999999, @@ -131,6 +131,23 @@ class DashboardModule(PgAdminModule): 'will be displayed on dashboards.') ) + self.graph_data_points = self.dashboard_preference.register( + 'display', 'graph_data_points', + gettext("Show graph data points?"), 'boolean', False, + category_label=gettext('Display'), + help_str=gettext('If set to True, data points will be ' + 'visible on graph lines.') + ) + + self.graph_mouse_track = self.dashboard_preference.register( + 'display', 'graph_mouse_track', + gettext("Show mouse hover tooltip?"), 'boolean', False, + category_label=gettext('Display'), + help_str=gettext('If set to True, tooltip will appear on mouse ' + 'hover on the graph lines giving the data point ' + 'details') + ) + def get_exposed_url_endpoints(self): """ Returns: diff --git a/web/pgadmin/dashboard/static/js/charting.js b/web/pgadmin/dashboard/static/js/charting.js new file mode 100644 index 00000000..1e275a5f --- /dev/null +++ b/web/pgadmin/dashboard/static/js/charting.js @@ -0,0 +1,99 @@ +import Flotr from 'flotr2'; + +export class Chart { + constructor(container, options) { + this.chartApi = Flotr; + /* Html Node where the graph goes */ + this._container = container; + /* Graph library options */ + this._options = {}; + this._defaultOptions = { + legend: { + position: 'nw', + backgroundColor: '#D2E8FF', + }, + lines: { + show: true, + lineWidth: 2, + }, + shadowSize: 0, + resolution : 3, + }; + + this._dataset = null; + this._tooltipFormatter = null; + /* Just to store other data related to charts. Used nowhere here in the module */ + this._otherData = {}; + this.setOptions(options); + } + + getContainer() { + return this._container; + } + + getContainerDimensions() { + return { + height: this._container.clientHeight, + width: this._container.clientWidth, + }; + } + + getOptions() { + return this._options; + } + + /* This should be changed if library changed */ + setOptions(options, mergeOptions=true) { + /* If mergeOptions then merge the options, else replace existing options */ + if(mergeOptions) { + this._options = {...this._defaultOptions, ...this._options, ...options}; + } else { + this._options = {...this._defaultOptions, ...options}; + } + } + + removeOptions(optionKey) { + if(this._options[optionKey]) { + delete this._options[optionKey]; + } + } + + getOtherData(key) { + return this._otherData[key]; + } + + setOtherData(key, value) { + this._otherData[key] = value; + } + + isVisible() { + let dim = this.getContainerDimensions(); + return (dim.height > 0 && dim.width > 0); + } + + isInPage() { + return (this._container === document.body) ? false : document.body.contains(this._container); + } + + setTooltipFormatter(tooltipFormatter) { + let opt = this.getOptions(); + + this._tooltipFormatter = tooltipFormatter; + + if(this._tooltipFormatter) { + this.setOptions({ + mouse: { + ...opt.mouse, + trackFormatter: this._tooltipFormatter, + }, + }); + } + } + + draw(dataset) { + this._dataset = dataset; + if(this._container) { + Flotr.draw(this._container, this._dataset, this._options); + } + } +} diff --git a/web/pgadmin/dashboard/static/js/dashboard.js b/web/pgadmin/dashboard/static/js/dashboard.js index 514b2f19..84804ac2 100644 --- a/web/pgadmin/dashboard/static/js/dashboard.js +++ b/web/pgadmin/dashboard/static/js/dashboard.js @@ -1,11 +1,11 @@ define('pgadmin.dashboard', [ 'sources/url_for', 'sources/gettext', 'require', 'jquery', 'underscore', - 'sources/pgadmin', 'backbone', 'backgrid', 'flotr2', + 'sources/pgadmin', 'backbone', 'backgrid', './charting', 'pgadmin.alertifyjs', 'pgadmin.backform', 'sources/nodes/dashboard', 'backgrid.filter', 'pgadmin.browser', 'bootstrap', 'wcdocker', ], function( - url_for, gettext, r, $, _, pgAdmin, Backbone, Backgrid, Flotr, + url_for, gettext, r, $, _, pgAdmin, Backbone, Backgrid, charting, Alertify, Backform, NodesDashboard ) { @@ -210,10 +210,8 @@ define('pgadmin.dashboard', [ // Load the default welcome dashboard var url = url_for('dashboard.index'); - /* Store the interval ids of the graph interval functions so that we can clear - * them when graphs are disabled - */ - this.intervalIds = {}; + /* Store the chart objects and there interval ids in this store */ + this.chartStore = {}; var dashboardPanel = pgBrowser.panels['dashboard'].panel; if (dashboardPanel) { @@ -266,7 +264,7 @@ define('pgadmin.dashboard', [ !_.isUndefined(itemData.connected) && itemData.connected !== true ) { - self.clearIntervalId(); + self.clearChartFromStore(); } } else if (itemData && itemData._type) { var treeHierarchy = node.getTreeNodeHierarchy(item), @@ -331,8 +329,8 @@ define('pgadmin.dashboard', [ ) { $(div).empty(); - /* Clear all the interval functions of previous dashboards */ - self.clearIntervalId(); + /* Clear all the charts previous dashboards */ + self.clearChartFromStore(); $.ajax({ url: url, @@ -356,8 +354,8 @@ define('pgadmin.dashboard', [ !_.isUndefined(itemData.connected) && itemData.connected !== true ) { - /* Clear all the interval functions of previous dashboards */ - self.clearIntervalId(); + /* Clear all the charts previous dashboards */ + self.clearChartFromStore(); } $(div).html( '
' @@ -371,7 +369,7 @@ define('pgadmin.dashboard', [ } }, - renderChartLoop: function(container, sid, did, url, options, counter, refresh) { + renderChartLoop: function(chartObj, sid, did, url, counter, refresh) { var data = [], dataset = []; @@ -386,24 +384,22 @@ define('pgadmin.dashboard', [ dataType: 'html', }) .done(function(resp) { - $(container).removeClass('graph-error'); + $(chartObj.getContainer()).removeClass('graph-error'); data = JSON.parse(resp); - if (!dashboardVisible) - return; var y = 0, x; if (dataset.length == 0) { if (counter == true) { // Have we stashed initial values? - if (_.isUndefined($(container).data('counter_previous_vals'))) { - $(container).data('counter_previous_vals', data[0]); + if (_.isUndefined(chartObj.getOtherData('counter_previous_vals'))) { + chartObj.setOtherData('counter_previous_vals', data[0]); } else { // Create the initial data structure for (x in data[0]) { dataset.push({ 'data': [ - [0, data[0][x] - $(container).data('counter_previous_vals')[x]], + [0, data[0][x] - chartObj.getOtherData('counter_previous_vals')[x]], ], 'label': x, }); @@ -429,10 +425,10 @@ define('pgadmin.dashboard', [ } else { // Store the current value, minus the previous one we stashed. // It's possible the tab has been reloaded, in which case out previous values are gone - if (_.isUndefined($(container).data('counter_previous_vals'))) + if (_.isUndefined(chartObj.getOtherData('counter_previous_vals'))) return; - dataset[y]['data'].unshift([0, data[0][x] - $(container).data('counter_previous_vals')[x]]); + dataset[y]['data'].unshift([0, data[0][x] - chartObj.getOtherData('counter_previous_vals')[x]]); } // Reset the time index to get a proper scrolling display @@ -442,7 +438,7 @@ define('pgadmin.dashboard', [ y++; } - $(container).data('counter_previous_vals', data[0]); + chartObj.setOtherData('counter_previous_vals', data[0]); } // Remove uneeded elements @@ -453,12 +449,9 @@ define('pgadmin.dashboard', [ } } - // Draw Graph, if the container still exists and has a size - var dashboardPanel = pgBrowser.panels['dashboard'].panel; - var div = dashboardPanel.layout().scene().find('.pg-panel-content'); - if ($(div).find(container).length) { // Exists? - if (container.clientHeight > 0 && container.clientWidth > 0) { // Not hidden? - Flotr.draw(container, dataset, options); + if (chartObj.isInPage()) { + if (chartObj.isVisible()) { + chartObj.draw(dataset); } } else { return; @@ -487,8 +480,8 @@ define('pgadmin.dashboard', [ } } - $(container).addClass('graph-error'); - $(container).html( + $(chartObj.getContainer()).addClass('graph-error'); + $(chartObj.getContainer()).html( '' ); }); @@ -510,15 +503,41 @@ define('pgadmin.dashboard', [ // { data: [[0, y0], [1, y1]...], label: 'Label 3', [options] } // ] - let self = this; - if(self.intervalIds[chartName] + let self = this, + tooltipFormatter = function(refresh, currVal) { + return(`Seconds ago: ${parseInt(currVal.x * refresh)} + Value: ${currVal.y}`); + }; + + if(self.chartStore[chartName] && self.old_preferences[prefName] != self.preferences[prefName]) { - self.clearIntervalId(chartName); + self.clearChartFromStore(chartName); } - if(!self.intervalIds[chartName]) { - self.intervalIds[chartName] = self.renderChartLoop( - container, self.sid, self.did, url, - options, counter, self.preferences[prefName] + + if(self.chartStore[chartName]) { + let chartObj = self.chartStore[chartName].chartObj; + chartObj.setOptions(options, false); + chartObj.setTooltipFormatter( + tooltipFormatter.bind(null, self.preferences[prefName]) + ); + } + + if(!self.chartStore[chartName]) { + + let chartObj = new charting.Chart(container, options); + + chartObj.setTooltipFormatter( + tooltipFormatter.bind(null, self.preferences[prefName]) + ); + + self.chartStore[chartName] = { + 'chartObj' : chartObj, + 'intervalId' : undefined, + }; + + self.chartStore[chartName]['intervalId'] = self.renderChartLoop( + self.chartStore[chartName]['chartObj'], self.sid, self.did, url, + counter, self.preferences[prefName] ); } }, @@ -666,17 +685,17 @@ define('pgadmin.dashboard', [ }); }, - clearIntervalId: function(intervalId) { + clearChartFromStore: function(chartName) { var self = this; - if(!intervalId){ - _.each(self.intervalIds, function(id, key) { - clearInterval(id); - delete self.intervalIds[key]; + if(!chartName){ + _.each(self.chartStore, function(chart, key) { + clearInterval(chart.intervalId); + delete self.chartStore[key]; }); } else { - clearInterval(self.intervalIds[intervalId]); - delete self.intervalIds[intervalId]; + clearInterval(self.chartStore[chartName].intervalId); + delete self.chartStore[chartName]; } }, @@ -737,20 +756,41 @@ define('pgadmin.dashboard', [ yaxis: { autoscale: 1, }, - legend: { - position: 'nw', - backgroundColor: '#D2E8FF', - }, - shadowSize: 0, - resolution : 5, }; + if(self.preferences.graph_data_points) { + /* Merge data points related options */ + options_line = { + ...options_line, + ...{ + points: { + show:true, + radius: 1, + hitRadius: 3, + }, + }, + }; + } + + if(self.preferences.graph_mouse_track) { + /* Merge mouse track related options */ + options_line = { + ...options_line, + ...{ + mouse: { + track:true, + position: 'sw', + }, + }, + }; + } + if(self.preferences.show_graphs && $('#dashboard-graphs').hasClass('dashboard-hidden')) { $('#dashboard-graphs').removeClass('dashboard-hidden'); } else if(!self.preferences.show_graphs) { $('#dashboard-graphs').addClass('dashboard-hidden'); - self.clearIntervalId(); + self.clearChartFromStore(); } if (self.preferences.show_activity && $('#dashboard-activity').hasClass('dashboard-hidden')) { @@ -1344,8 +1384,11 @@ define('pgadmin.dashboard', [ }); } }, - toggleVisibility: function(flag) { - dashboardVisible = flag; + toggleVisibility: function(visible, closed=false) { + dashboardVisible = visible; + if(closed) { + this.clearChartFromStore(); + } }, can_take_action: function(m) { // We will validate if user is allowed to cancel the active query diff --git a/web/regression/javascript/dashboard/charting_spec.js b/web/regression/javascript/dashboard/charting_spec.js new file mode 100644 index 00000000..00652090 --- /dev/null +++ b/web/regression/javascript/dashboard/charting_spec.js @@ -0,0 +1,91 @@ +import $ from 'jquery'; +import {Chart} from 'top/dashboard/static/js/charting'; + +describe('In charting related testcases', ()=> { + let chartObj = undefined, + chartDiv = undefined, + options = {}; + + beforeEach(()=> { + $('body').append( + '' + ); + chartDiv = $('#charting-test-container')[0]; + chartObj = new Chart(chartDiv, options); + }); + + it('Chart api should be defined', ()=>{ + expect(chartObj.chartApi).toBeDefined(); + }); + + it('Return the correct container', ()=>{ + expect(chartObj.getContainer()).toBe(chartDiv); + }); + + it('Returns the container dimensions', ()=>{ + let dim = chartObj.getContainerDimensions(); + expect(dim.height).toBeDefined(); + expect(dim.width).toBeDefined(); + }); + + it('Check if options are set', ()=>{ + chartObj.setOptions({ + mouse: { + track:true, + }, + }); + + let opt = chartObj.getOptions(); + + expect(opt.mouse).toBeDefined(); + }); + + it('Check if options are set with mergeOptions false', ()=>{ + let overOpt = { + mouse: { + track:true, + }, + }; + chartObj.setOptions(overOpt, false); + + let newOptShouldBe = {...chartObj._defaultOptions, ...overOpt}; + + let opt = chartObj.getOptions(); + expect(JSON.stringify(opt)).toEqual(JSON.stringify(newOptShouldBe)); + }); + + it('Check if other data is set', ()=>{ + chartObj.setOtherData('some_val', 1); + expect(chartObj._otherData['some_val']).toEqual(1); + }); + + it('Check if other data is get', ()=>{ + chartObj.setOtherData('some_val', 1); + expect(chartObj.getOtherData('some_val')).toEqual(1); + }); + + it('Check if isVisible returns correct', ()=>{ + let dimSpy = spyOn(chartObj, 'getContainerDimensions'); + + dimSpy.and.returnValue({ + height: 1, width: 1, + }); + expect(chartObj.isVisible()).toBe(true); + dimSpy.and.stub(); + + dimSpy.and.returnValue({ + height: 0, width: 0, + }); + expect(chartObj.isVisible()).toBe(false); + }); + + it('Check if isInPage returns correct', ()=>{ + expect(chartObj.isInPage()).toBe(true); + $('body').find('#charting-test-container').remove(); + expect(chartObj.isInPage()).toBe(false); + }); + + afterEach(()=>{ + $('body').find('#charting-test-container').remove(); + }); +}); diff --git a/web/webpack.config.js b/web/webpack.config.js index 4dbf1357..67466af4 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -144,6 +144,7 @@ module.exports = { loader: 'babel-loader', options: { presets: ['es2015', 'react'], + plugins: ['transform-object-rest-spread'], }, }, }, { diff --git a/web/webpack.test.config.js b/web/webpack.test.config.js index 757107ca..a7118711 100644 --- a/web/webpack.test.config.js +++ b/web/webpack.test.config.js @@ -58,6 +58,7 @@ module.exports = { resolve: { extensions: ['.js', '.jsx'], alias: { + 'top': path.join(__dirname, './pgadmin'), 'jquery': path.join(__dirname, './node_modules/jquery/dist/jquery'), 'alertify': path.join(__dirname, './node_modules/alertifyjs/build/alertify'), 'jquery.event.drag': path.join(__dirname, './node_modules/slickgrid/lib/jquery.event.drag-2.3.0'), @@ -75,6 +76,8 @@ module.exports = { 'slickgrid': nodeModulesDir + '/slickgrid/', 'slickgrid.plugins': nodeModulesDir + '/slickgrid/plugins/', 'slickgrid.grid': nodeModulesDir + '/slickgrid/slick.grid', + 'bean': path.join(__dirname, './node_modules/flotr2/lib/bean'), + 'flotr2': path.join(__dirname, './node_modules/flotr2/flotr2.amd'), 'browser': path.resolve(__dirname, 'pgadmin/browser/static/js'), 'pgadmin': sourcesDir + '/js/pgadmin', 'pgadmin.sqlfoldcode': sourcesDir + '/js/codemirror/addon/fold/pgadmin-sqlfoldcode',