Replace charting library Flotr2 with ChartJS using React. Fixes #3904

This commit is contained in:
Aditya Toshniwal
2020-07-29 14:49:22 +05:30
committed by Akshay Joshi
parent 5820b9521e
commit f715373218
25 changed files with 2004 additions and 826 deletions

View File

@@ -16,12 +16,17 @@ module.exports = {
},
'extends': [
'eslint:recommended',
'plugin:react/recommended',
],
'parserOptions': {
'ecmaVersion': 2018,
'ecmaFeatures': {
'jsx': true
},
'sourceType': 'module',
},
'plugins': [
'react'
],
'globals': {
'_': true,
@@ -49,4 +54,9 @@ module.exports = {
// We need to exclude below for RegEx case
"no-useless-escape": 0,
},
'settings': {
'react': {
'version': 'detect',
},
}
};

View File

@@ -16,12 +16,16 @@
"copy-webpack-plugin": "^5.1.0",
"core-js": "^3.2.1",
"cross-env": "^5.2.0",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "5.15.1",
"eslint-plugin-react": "^7.20.3",
"file-loader": "^3.0.1",
"iconfont-webpack-plugin": "^4.2.1",
"image-webpack-loader": "^4.6.0",
"is-docker": "^1.1.0",
"jasmine-core": "~3.3.0",
"jasmine-enzyme": "^7.1.2",
"karma": "^4.0.1",
"karma-babel-preprocessor": "^8.0.0",
"karma-browserify": "~6.0.0",
@@ -36,6 +40,7 @@
"optimize-css-assets-webpack-plugin": "^5.0.1",
"popper.js": "^1.14.7",
"postcss-loader": "^3.0.0",
"prop-types": "^15.7.2",
"raw-loader": "^1.0.0",
"sass": "^1.24.4",
"sass-loader": "^7.1.0",
@@ -50,6 +55,8 @@
"yarn-audit-html": "^1.1.0"
},
"dependencies": {
"@babel/plugin-proposal-class-properties": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"@simonwep/pickr": "^1.5.1",
"acitree": "git+https://github.com/imsurinder90/jquery-aciTree.git#rc.7",
"alertifyjs": "git+https://github.com/EnterpriseDB/AlertifyJS/#72c1d794f5b6d4ec13a68d123c08f19021afe263",
@@ -67,12 +74,12 @@
"bootstrap4-toggle": "3.4.0",
"bowser": "2.1.2",
"browserify": "~16.2.3",
"chart.js": "^2.9.3",
"codemirror": "^5.54.0",
"css-loader": "2.1.0",
"cssnano": "^4.1.10",
"dropzone": "^5.5.1",
"exports-loader": "~0.7.0",
"flotr2": "git+https://github.com/EnterpriseDB/Flotr2.git",
"font-awesome": "^4.7.0",
"immutability-helper": "^3.0.0",
"imports-loader": "^0.8.0",
@@ -85,6 +92,8 @@
"moment": "^2.24.0",
"moment-timezone": "^0.5.23",
"mousetrap": "^1.6.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"requirejs": "~2.3.6",
"select2": "^4.0.6-rc.1",
"shim-loader": "^1.0.1",

View File

@@ -24,11 +24,6 @@
line-height: 30px;
}
.graph-container {
margin-top: 10px;
height: 150px;
}
.icon-postgres:before {
height: 43px;
margin-top: 13px;

View File

@@ -0,0 +1,51 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import ReactDOM from 'react-dom';
import Graphs from './Graphs';
export default class ChartsDOM {
constructor(container, preferences, sid, did, pageVisible=true) {
this.container = container;
this.preferences = preferences;
this.sid = sid;
this.did = did;
this.pageVisible = pageVisible;
}
mount() {
if(this.container && this.preferences.show_graphs) {
ReactDOM.render(<Graphs sid={this.sid} did={this.did} preferences={this.preferences} pageVisible={this.pageVisible}/>, this.container);
}
}
unmount() {
this.container && ReactDOM.unmountComponentAtNode(this.container);
}
setSidDid(sid, did) {
this.sid = sid;
this.did = did;
this.mount();
}
reflectPreferences(preferences) {
this.preferences = preferences;
if(preferences.show_graphs) {
this.mount();
} else {
this.unmount();
}
}
setPageVisible(visible) {
this.pageVisible = visible;
this.mount();
}
}

View File

@@ -0,0 +1,388 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useEffect, useRef, useState, useReducer, useCallback, useMemo } from 'react';
import {LineChart} from 'sources/chartjs';
import {ChartContainer, DashboardRowCol, DashboardRow} from './dashboard_components';
import url_for from 'sources/url_for';
import axios from 'axios';
import gettext from 'sources/gettext';
import {getGCD, getEpoch} from 'sources/utils';
import {useInterval, usePrevious} from 'sources/custom_hooks';
import PropTypes from 'prop-types';
export const X_AXIS_LENGTH = 75;
export const POINT_SIZE = 2;
/* Transform the labels data to suit ChartJS */
export function transformData(labels, refreshRate) {
const colors = ['#00BCD4', '#9CCC65', '#E64A19'];
let datasets = Object.keys(labels).map((label, i)=>{
return {
label: label,
data: labels[label] || [],
borderColor: colors[i],
backgroundColor: colors[i],
pointHitRadius: POINT_SIZE,
};
}) || [];
return {
labels: [...Array(X_AXIS_LENGTH).keys()],
datasets: datasets,
refreshRate: refreshRate,
};
}
/* Custom ChartJS legend callback */
export function legendCallback(chart) {
var text = [];
text.push('<div class="' + chart.id + '-legend d-flex">');
for (var i = 0; i < chart.data.datasets.length; i++) {
text.push('<div class="legend-value"><span style="background-color:' + chart.data.datasets[i].backgroundColor + '">&nbsp;&nbsp;&nbsp;&nbsp;</span>');
if (chart.data.datasets[i].label) {
text.push('<span class="legend-label">' + chart.data.datasets[i].label + '</span>');
}
text.push('</div>');
}
text.push('</div>');
return text.join('');
}
/* URL for fetching graphs data */
export function getStatsUrl(sid=-1, did=-1, chart_names=[]) {
let base_url = url_for('dashboard.dashboard_stats');
base_url += '/' + sid;
base_url += (did > 0) ? ('/' + did) : '';
base_url += '?chart_names=' + chart_names.join(',');
return base_url;
}
/* This will process incoming charts data add it the the previous charts
* data to get the new state.
*/
export function statsReducer(state, action) {
let newState = {};
if(action.reset) {
return action.reset;
}
if(!action.incoming) {
return state;
}
if(!action.counterData) {
action.counterData = action.incoming;
}
Object.keys(action.incoming).forEach(label => {
if(state[label]) {
if(state[label].length >= X_AXIS_LENGTH) {
state[label].unshift();
}
newState[label] = [
action.counter ? action.incoming[label] - action.counterData[label] : action.incoming[label],
...state[label],
];
} else {
newState[label] = [
action.counter ? action.incoming[label] - action.counterData[label] : action.incoming[label],
];
}
});
return newState;
}
const chartsDefault = {
'session_stats': {'Total': [], 'Active': [], 'Idle': []},
'tps_stats': {'Transactions': [], 'Commits': [], 'Rollbacks': []},
'ti_stats': {'Inserts': [], 'Updates': [], 'Delete': []},
'to_stats': {'Fetched': [], 'Returned': []},
'bio_stats': {'Reads': [], 'Hits': []},
};
export default function Graphs({preferences, sid, did, pageVisible, enablePoll=true}) {
const refreshOn = useRef(null);
const prevPrefernces = usePrevious(preferences);
const [sessionStats, sessionStatsReduce] = useReducer(statsReducer, chartsDefault['session_stats']);
const [tpsStats, tpsStatsReduce] = useReducer(statsReducer, chartsDefault['tps_stats']);
const [tiStats, tiStatsReduce] = useReducer(statsReducer, chartsDefault['ti_stats']);
const [toStats, toStatsReduce] = useReducer(statsReducer, chartsDefault['to_stats']);
const [bioStats, bioStatsReduce] = useReducer(statsReducer, chartsDefault['bio_stats']);
const [counterData, setCounterData] = useState({});
const [errorMsg, setErrorMsg] = useState(null);
const [pollDelay, setPollDelay] = useState(1000);
const [chartDrawnOnce, setChartDrawnOnce] = useState(false);
useEffect(()=>{
let calcPollDelay = false;
if(prevPrefernces) {
if(prevPrefernces['session_stats_refresh'] != preferences['session_stats_refresh']) {
sessionStatsReduce({reset: chartsDefault['session_stats']});
calcPollDelay = true;
}
if(prevPrefernces['tps_stats_refresh'] != preferences['tps_stats_refresh']) {
tpsStatsReduce({reset:chartsDefault['tps_stats']});
calcPollDelay = true;
}
if(prevPrefernces['ti_stats_refresh'] != preferences['ti_stats_refresh']) {
tiStatsReduce({reset:chartsDefault['ti_stats']});
calcPollDelay = true;
}
if(prevPrefernces['to_stats_refresh'] != preferences['to_stats_refresh']) {
toStatsReduce({reset:chartsDefault['to_stats']});
calcPollDelay = true;
}
if(prevPrefernces['bio_stats_refresh'] != preferences['bio_stats_refresh']) {
bioStatsReduce({reset:chartsDefault['bio_stats']});
calcPollDelay = true;
}
} else {
calcPollDelay = true;
}
if(calcPollDelay) {
setPollDelay(
getGCD(Object.keys(chartsDefault).map((name)=>preferences[name+'_refresh']))*1000
);
}
}, [preferences]);
useEffect(()=>{
/* Charts rendered are not visible when, the dashboard is hidden but later visible */
if(pageVisible && !chartDrawnOnce) {
setChartDrawnOnce(true);
}
}, [pageVisible]);
useInterval(()=>{
const currEpoch = getEpoch();
if(refreshOn.current === null) {
let tmpRef = {};
Object.keys(chartsDefault).forEach((name)=>{
tmpRef[name] = currEpoch;
});
refreshOn.current = tmpRef;
}
let getFor = [];
Object.keys(chartsDefault).forEach((name)=>{
if(currEpoch >= refreshOn.current[name]) {
getFor.push(name);
refreshOn.current[name] = currEpoch + preferences[name+'_refresh'];
}
});
let path = getStatsUrl(sid, did, getFor);
axios.get(path)
.then((resp)=>{
let data = resp.data;
setErrorMsg(null);
sessionStatsReduce({incoming: data['session_stats']});
tpsStatsReduce({incoming: data['tps_stats'], counter: true, counterData: counterData['tps_stats']});
tiStatsReduce({incoming: data['ti_stats'], counter: true, counterData: counterData['ti_stats']});
toStatsReduce({incoming: data['to_stats'], counter: true, counterData: counterData['to_stats']});
bioStatsReduce({incoming: data['bio_stats'], counter: true, counterData: counterData['bio_stats']});
setCounterData((prevCounterData)=>{
return {
...prevCounterData,
...data,
};
});
})
.catch((error)=>{
if(!errorMsg) {
sessionStatsReduce({reset: chartsDefault['session_stats']});
tpsStatsReduce({reset:chartsDefault['tps_stats']});
tiStatsReduce({reset:chartsDefault['ti_stats']});
toStatsReduce({reset:chartsDefault['to_stats']});
bioStatsReduce({reset:chartsDefault['bio_stats']});
setCounterData({});
if(error.response) {
if (error.response.status === 428) {
setErrorMsg(gettext('Please connect to the selected server to view the graph.'));
} else {
setErrorMsg(gettext('An error occurred whilst rendering the graph.'));
}
} else if(error.request) {
setErrorMsg(gettext('Not connected to the server or the connection to the server has been closed.'));
return;
} else {
console.error(error);
}
}
});
}, enablePoll ? pollDelay : -1);
return (
<>
<div data-testid='graph-poll-delay' className='d-none'>{pollDelay}</div>
{chartDrawnOnce &&
<GraphsWrapper
sessionStats={transformData(sessionStats, preferences['session_stats_refresh'])}
tpsStats={transformData(tpsStats, preferences['tps_stats_refresh'])}
tiStats={transformData(tiStats, preferences['ti_stats_refresh'])}
toStats={transformData(toStats, preferences['to_stats_refresh'])}
bioStats={transformData(bioStats, preferences['bio_stats_refresh'])}
errorMsg={errorMsg}
showTooltip={preferences['graph_mouse_track']}
showDataPoints={preferences['graph_data_points']}
isDatabase={did > 0}
/>
}
</>
);
}
Graphs.propTypes = {
preferences: PropTypes.object.isRequired,
sid: PropTypes.oneOfType([
PropTypes.string.isRequired,
PropTypes.number.isRequired,
]),
did: PropTypes.oneOfType([
PropTypes.string.isRequired,
PropTypes.number.isRequired,
]),
enablePoll: PropTypes.bool,
};
export function GraphsWrapper(props) {
const sessionStatsLegendRef = useRef();
const tpsStatsLegendRef = useRef();
const tiStatsLegendRef = useRef();
const toStatsLegendRef = useRef();
const bioStatsLegendRef = useRef();
const options = useMemo(()=>({
legendCallback: legendCallback,
animation: {
duration: 0,
},
hover: {
animationDuration: 0,
},
responsiveAnimationDuration: 0,
legend: {
display: false,
},
elements: {
point: {
radius: props.showDataPoints ? POINT_SIZE : 0,
},
},
tooltips: {
enabled: props.showTooltip,
callbacks: {
title: function(tooltipItem, data) {
let title = '';
try {
title = parseInt(tooltipItem[0].xLabel)*data.refreshRate + gettext(' seconds ago');
} catch (error) {
title = '';
}
return title;
},
},
},
scales: {
yAxes: [{
ticks: {
min: 0,
userCallback: function(label) {
if (Math.floor(label) === label) {
return label;
}
},
fontColor: getComputedStyle(document.documentElement).getPropertyValue('--color-fg'),
},
gridLines: {
drawBorder: false,
zeroLineColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color'),
color: getComputedStyle(document.documentElement).getPropertyValue('--border-color'),
},
}],
xAxes: [{
display: false,
gridLines: {
display: false,
},
ticks: {
display: false,
reverse: true,
},
}],
},
}), [props.showTooltip, props.showDataPoints]);
const updateOptions = useMemo(()=>({duration: 0}), []);
const onInitCallback = useCallback(
(legendRef)=>(chart)=>{
legendRef.current.innerHTML = chart.generateLegend();
}
);
return (
<>
<DashboardRow>
<DashboardRowCol breakpoint='md' parts={6}>
<ChartContainer id='sessions-graph' title={props.isDatabase ? gettext('Database sessions') : gettext('Server sessions')} legendRef={sessionStatsLegendRef} errorMsg={props.errorMsg}>
<LineChart options={options} data={props.sessionStats} updateOptions={updateOptions}
onInit={onInitCallback(sessionStatsLegendRef)}/>
</ChartContainer>
</DashboardRowCol>
<DashboardRowCol breakpoint='md' parts={6}>
<ChartContainer id='tps-graph' title={gettext('Transactions per second')} legendRef={tpsStatsLegendRef} errorMsg={props.errorMsg}>
<LineChart options={options} data={props.tpsStats} updateOptions={updateOptions}
onInit={onInitCallback(tpsStatsLegendRef)}/>
</ChartContainer>
</DashboardRowCol>
</DashboardRow>
<DashboardRow>
<DashboardRowCol breakpoint='md' parts={4}>
<ChartContainer id='ti-graph' title={gettext('Tuples in')} legendRef={tiStatsLegendRef} errorMsg={props.errorMsg}>
<LineChart options={options} data={props.tiStats} updateOptions={updateOptions}
onInit={onInitCallback(tiStatsLegendRef)}/>
</ChartContainer>
</DashboardRowCol>
<DashboardRowCol breakpoint='md' parts={4}>
<ChartContainer id='to-graph' title={gettext('Tuples out')} legendRef={toStatsLegendRef} errorMsg={props.errorMsg}>
<LineChart options={options} data={props.toStats} updateOptions={updateOptions}
onInit={onInitCallback(toStatsLegendRef)}/>
</ChartContainer>
</DashboardRowCol>
<DashboardRowCol breakpoint='md' parts={4}>
<ChartContainer id='bio-graph' title={gettext('Block I/O')} legendRef={bioStatsLegendRef} errorMsg={props.errorMsg}>
<LineChart options={options} data={props.bioStats} updateOptions={updateOptions}
onInit={onInitCallback(bioStatsLegendRef)}/>
</ChartContainer>
</DashboardRowCol>
</DashboardRow>
</>
);
}
const propTypeStats = PropTypes.shape({
labels: PropTypes.array.isRequired,
datasets: PropTypes.array,
refreshRate: PropTypes.number.isRequired,
});
GraphsWrapper.propTypes = {
sessionStats: propTypeStats.isRequired,
tpsStats: propTypeStats.isRequired,
tiStats: propTypeStats.isRequired,
toStats: propTypeStats.isRequired,
bioStats: propTypeStats.isRequired,
errorMsg: PropTypes.string,
showTooltip: PropTypes.bool.isRequired,
showDataPoints: PropTypes.bool.isRequired,
isDatabase: PropTypes.bool.isRequired,
};

View File

@@ -1,121 +0,0 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
export class Chart {
constructor(container, options) {
let self = this;
require.ensure(['flotr2'], function(require) {
self.chartApi = require('flotr2');
}, function(error){
throw(error);
}, 'chart');
/* 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,
grid: {
color: 'transparent',
tickColor: '#8f8f8f',
},
};
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) {
if(this._otherData[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) {
if(this.chartApi) {
this.chartApi.draw(this._container, this._dataset, this._options);
}
}
}
}

View File

@@ -9,12 +9,12 @@
define('pgadmin.dashboard', [
'sources/url_for', 'sources/gettext', 'require', 'jquery', 'underscore',
'sources/pgadmin', 'backbone', 'backgrid', './charting',
'sources/pgadmin', 'backbone', 'backgrid',
'pgadmin.alertifyjs', 'pgadmin.backform', 'sources/nodes/dashboard',
'sources/utils', 'sources/window', 'pgadmin.browser', 'bootstrap', 'wcdocker',
'sources/window', './ChartsDOM', 'pgadmin.browser', 'bootstrap', 'wcdocker',
], function(
url_for, gettext, r, $, _, pgAdmin, Backbone, Backgrid, charting,
Alertify, Backform, NodesDashboard, commonUtils, pgWindow
url_for, gettext, r, $, _, pgAdmin, Backbone, Backgrid,
Alertify, Backform, NodesDashboard, pgWindow, ChartsDOM
) {
pgAdmin.Browser = pgAdmin.Browser || {};
@@ -203,6 +203,7 @@ define('pgadmin.dashboard', [
return;
this.initialized = true;
this.chartsDomObj = null;
this.sid = this.did = -1;
this.version = -1;
@@ -221,10 +222,6 @@ define('pgadmin.dashboard', [
// Load the default welcome dashboard
var url = url_for('dashboard.index');
/* Store the chart objects, refresh freq and next refresh time */
this.chart_store = {};
this.charts_poller_int_id = -1;
var dashboardPanel = pgBrowser.panels['dashboard'].panel;
if (dashboardPanel) {
var div = dashboardPanel.layout().scene().find('.pg-panel-content');
@@ -283,18 +280,7 @@ define('pgadmin.dashboard', [
object_selected: function(item, itemData, node) {
let self = this;
if (dashboardVisible === false) {
/*
* Clear all the interval functions, even when dashboard is not
* visible (in case of connection of the object got disconnected).
*/
if (
!_.isUndefined(itemData.connected) &&
itemData.connected !== true
) {
self.clearChartFromStore();
}
} else if (itemData && itemData._type) {
if (dashboardVisible && itemData && itemData._type) {
var treeHierarchy = node.getTreeNodeHierarchy(item),
url = NodesDashboard.url(itemData, item, treeHierarchy);
@@ -355,11 +341,9 @@ define('pgadmin.dashboard', [
$(dashboardPanel).data('server_status') == false
)
) {
this.chartsDomObj && this.chartsDomObj.unmount();
$(div).empty();
/* Clear all the charts previous dashboards */
self.clearChartFromStore();
let ajaxHook = function() {
$.ajax({
url: url,
@@ -393,14 +377,8 @@ define('pgadmin.dashboard', [
$(dashboardPanel).data('server_status', true);
}
} else {
this.chartsDomObj && this.chartsDomObj.unmount();
$(div).empty();
if (
!_.isUndefined(itemData.connected) &&
itemData.connected !== true
) {
/* Clear all the charts previous dashboards */
self.clearChartFromStore();
}
$(div).html(
'<div class="pg-panel-message" role="alert">' + gettext('Please connect to the selected server to view the dashboard.') + '</div>'
);
@@ -413,218 +391,6 @@ define('pgadmin.dashboard', [
}
},
// Render the charts
renderCharts: function(charts_config) {
let self = this,
tooltipFormatter = function(refresh, currVal) {
return(`Seconds ago: ${parseInt(currVal.x * refresh)}</br>
Value: ${currVal.y}`);
},
curr_epoch=commonUtils.getEpoch();
self.stopChartsPoller();
charts_config.map((chart_config) => {
if(self.chart_store[chart_config.chart_name]
&& self.old_preferences[chart_config.refresh_pref_name] !=
self.preferences[chart_config.refresh_pref_name]) {
self.clearChartFromStore(chart_config.chart_name);
}
if(self.chart_store[chart_config.chart_name]) {
let chart_obj = self.chart_store[chart_config.chart_name].chart_obj;
chart_obj.setOptions(chart_config.options, false);
chart_obj.setTooltipFormatter(
tooltipFormatter.bind(null, self.preferences[chart_config.refresh_pref_name])
);
}
if(!self.chart_store[chart_config.chart_name]) {
let chart_obj = new charting.Chart(chart_config.container, chart_config.options);
chart_obj.setTooltipFormatter(
tooltipFormatter.bind(null, self.preferences[chart_config.refresh_pref_name])
);
chart_obj.setOtherData('counter', chart_config.counter);
self.chart_store[chart_config.chart_name] = {
'chart_obj' : chart_obj,
'refresh_on': curr_epoch,
'refresh_rate': self.preferences[chart_config.refresh_pref_name],
};
}
});
self.startChartsPoller(self.chart_store, self.sid, self.did);
},
getStatsUrl: function(sid=-1, did=-1, chart_names=[]) {
let base_url = url_for('dashboard.dashboard_stats');
base_url += '/' + sid;
base_url += (did > 0) ? ('/' + did) : '';
base_url += '?chart_names=' + chart_names.join(',');
return base_url;
},
updateChart: function(chart_obj, new_data){
// Dataset format:
// [
// { data: [[0, y0], [1, y1]...], label: 'Label 1', [options] },
// { data: [[0, y0], [1, y1]...], label: 'Label 2', [options] },
// { data: [[0, y0], [1, y1]...], label: 'Label 3', [options] }
// ]
let dataset = chart_obj.getOtherData('dataset') || [],
counter_prev_data = chart_obj.getOtherData('counter_prev_data') || new_data,
counter = chart_obj.getOtherData('counter') || false;
if (dataset.length == 0) {
// Create the initial data structure
for (let label in new_data) {
dataset.push({
'data': [
[0, counter ? (new_data[label] - counter_prev_data[label]) : new_data[label]],
],
'label': label,
});
}
} else {
Object.keys(new_data).forEach((label, label_ind) => {
// Push new values onto the existing data structure
// If this is a counter stat, we need to subtract the previous value
if (!counter) {
dataset[label_ind]['data'].unshift([0, new_data[label]]);
} 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(counter_prev_data))
return;
dataset[label_ind]['data'].unshift([0, new_data[label] - counter_prev_data[label]]);
}
// Reset the time index to get a proper scrolling display
for (var time_ind = 0; time_ind < dataset[label_ind]['data'].length; time_ind++) {
dataset[label_ind]['data'][time_ind][0] = time_ind;
}
});
counter_prev_data = new_data;
}
// Remove old data points
for (let label_ind = 0; label_ind < dataset.length; label_ind++) {
if (dataset[label_ind]['data'].length > 101) {
dataset[label_ind]['data'].pop();
}
}
chart_obj.setOtherData('dataset', dataset);
chart_obj.setOtherData('counter_prev_data', counter_prev_data);
if (chart_obj.isInPage()) {
if (chart_obj.isVisible()) {
chart_obj.draw(dataset);
}
} else {
return;
}
},
stopChartsPoller: function() {
clearInterval(this.charts_poller_int_id);
},
startChartsPoller: function(chart_store, sid, did) {
let self = this;
/* polling will the greatest common divisor of the refresh rates*/
let poll_interval = commonUtils.getGCD(
Object.values(chart_store).map(item => item.refresh_rate)
);
const WAIT_COUNTER = 3;
let last_poll_wait_counter = 0;
let resp_not_received_counter = 0;
/* Stop if running, only one poller lives */
self.stopChartsPoller();
var thePollingFunc = function() {
let curr_epoch = commonUtils.getEpoch();
let chart_names_to_get = [];
for(let chart_name in chart_store) {
/* when its time to get the data */
if(chart_store[chart_name].refresh_on <= curr_epoch) {
/* set the next trigger point */
chart_store[chart_name].refresh_on = curr_epoch + chart_store[chart_name].refresh_rate;
chart_names_to_get.push(chart_name);
}
}
/* If none of the chart wants data, don't trouble
* If response not received from prev poll, don't trouble !!
*/
if(chart_names_to_get.length == 0 || last_poll_wait_counter > 0 || resp_not_received_counter >= WAIT_COUNTER) {
/* reduce the number of tries, request should be sent if last_poll_wait_counter
* completes WAIT_COUNTER times.*/
last_poll_wait_counter--;
return;
}
var path = self.getStatsUrl(sid, did, chart_names_to_get);
resp_not_received_counter++;
$.ajax({
url: path,
type: 'GET',
})
.done(function(resp) {
for(let chart_name in resp) {
let chart_obj = chart_store[chart_name].chart_obj;
$(chart_obj.getContainer()).removeClass('graph-error');
self.updateChart(chart_obj, resp[chart_name]);
}
})
.fail(function(xhr) {
let err = '';
let msg = '';
let cls = 'info';
if (xhr.readyState === 0) {
msg = gettext('Not connected to the server or the connection to the server has been closed.');
} else {
err = JSON.parse(xhr.responseText);
msg = err.errormsg;
// If we get a 428, it means the server isn't connected
if (xhr.status === 428) {
if (_.isUndefined(msg) || _.isNull(msg)) {
msg = gettext('Please connect to the selected server to view the graph.');
}
} else {
msg = gettext('An error occurred whilst rendering the graph.');
cls = 'error';
}
}
for(let chart_name in chart_store) {
let chart_obj = chart_store[chart_name].chart_obj;
$(chart_obj.getContainer()).addClass('graph-error');
$(chart_obj.getContainer()).html(
'<div class="pg-panel-' + cls + ' pg-panel-message" role="alert">' + msg + '</div>'
);
}
})
.always(function() {
last_poll_wait_counter = 0;
resp_not_received_counter--;
});
last_poll_wait_counter = WAIT_COUNTER;
};
/* Execute once for the first time as setInterval will not do */
thePollingFunc();
self.charts_poller_int_id = setInterval(thePollingFunc, poll_interval * 1000);
},
// Handler function to support the "Add Server" link
add_new_server: function() {
if (pgBrowser && pgBrowser.tree) {
@@ -759,23 +525,18 @@ define('pgadmin.dashboard', [
});
},
clearChartFromStore: function(chartName) {
var self = this;
if(!chartName){
self.stopChartsPoller();
_.each(self.chart_store, function(chart, key) {
delete self.chart_store[key];
});
}
else {
delete self.chart_store[chartName];
}
},
// Rock n' roll on the dashboard
init_dashboard: function() {
let self = this;
this.chartsDomObj = new ChartsDOM.default(
document.getElementById('dashboard-graphs'),
self.preferences,
self.sid,
self.did,
$('.dashboard-container')[0].clientHeight <=0 ? false : true
);
/* Cache may take time to load for the first time
* Keep trying till available
*/
@@ -796,81 +557,14 @@ define('pgadmin.dashboard', [
pgBrowser.onPreferencesChange('dashboards', function() {
self.reflectPreferences();
});
},
reflectPreferences: function() {
var self = this;
var tickColor = getComputedStyle(document.documentElement).getPropertyValue('--border-color');
/* We will use old preferences for selective graph updates on preference change */
if(self.preferences) {
self.old_preferences = self.preferences;
self.preferences = pgWindow.default.pgAdmin.Browser.get_preferences_for_module('dashboards');
}
else {
self.preferences = pgWindow.default.pgAdmin.Browser.get_preferences_for_module('dashboards');
self.old_preferences = self.preferences;
}
self.preferences = pgWindow.default.pgAdmin.Browser.get_preferences_for_module('dashboards');
this.chartsDomObj.reflectPreferences(self.preferences);
if(is_server_dashboard || is_database_dashboard) {
/* Common things can come here */
var div_sessions = $('.dashboard-container').find('#graph-sessions')[0];
var div_tps = $('.dashboard-container').find('#graph-tps')[0];
var div_ti = $('.dashboard-container').find('#graph-ti')[0];
var div_to = $('.dashboard-container').find('#graph-to')[0];
var div_bio = $('.dashboard-container').find('#graph-bio')[0];
var options_line = {
parseFloat: false,
xaxis: {
min: 100,
max: 0,
autoscale: 0,
},
yaxis: {
autoscale: 1,
},
grid: {
color: 'transparent',
tickColor: tickColor,
},
};
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.clearChartFromStore();
}
if (self.preferences.show_activity && $('#dashboard-activity').hasClass('dashboard-hidden')) {
$('#dashboard-activity').removeClass('dashboard-hidden');
}
@@ -878,46 +572,11 @@ define('pgadmin.dashboard', [
$('#dashboard-activity').addClass('dashboard-hidden');
}
if(self.preferences.show_graphs) {
// Render the graphs
pgAdmin.Dashboard.renderCharts([{
chart_name: 'session_stats',
container: div_sessions,
options: options_line,
counter: false,
refresh_pref_name: 'session_stats_refresh',
}, {
chart_name: 'tps_stats',
container: div_tps,
options: options_line,
counter: true,
refresh_pref_name: 'tps_stats_refresh',
}, {
chart_name: 'ti_stats',
container: div_ti,
options: options_line,
counter: true,
refresh_pref_name: 'ti_stats_refresh',
}, {
chart_name: 'to_stats',
container: div_to,
options: options_line,
counter: true,
refresh_pref_name: 'to_stats_refresh',
}, {
chart_name: 'bio_stats',
container: div_bio,
options: options_line,
counter: true,
refresh_pref_name: 'bio_stats_refresh',
}]);
if (self.preferences.show_activity && $('#dashboard-activity').hasClass('dashboard-hidden')) {
$('#dashboard-activity').removeClass('dashboard-hidden');
}
if(!self.preferences.show_graphs && !self.preferences.show_activity) {
$('#dashboard-none-show').removeClass('dashboard-hidden');
}
else {
$('#dashboard-none-show').addClass('dashboard-hidden');
else if(!self.preferences.show_activity) {
$('#dashboard-activity').addClass('dashboard-hidden');
}
/* Dashboard specific preferences can be updated in the
@@ -1440,7 +1099,9 @@ define('pgadmin.dashboard', [
toggleVisibility: function(visible, closed=false) {
dashboardVisible = visible;
if(closed) {
this.clearChartFromStore();
this.chartsDomObj && this.chartsDomObj.unmount();
} else {
this.chartsDomObj && this.chartsDomObj.setPageVisible(dashboardVisible);
}
},
can_take_action: function(m) {

View File

@@ -0,0 +1,74 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import PropTypes from 'prop-types';
export function ChartContainer(props) {
return (
<div className="card dashboard-graph" role="object-document" tabIndex="0" aria-labelledby={props.id}>
<div className="card-header">
<div className="d-flex">
<div id={props.id}>{props.title}</div>
<div className="ml-auto my-auto legend" ref={props.legendRef}></div>
</div>
</div>
<div className="card-body dashboard-graph-body">
<div className={'chart-wrapper ' + (props.errorMsg ? 'd-none': '')}>
{props.children}
</div>
<ChartError message={props.errorMsg} />
</div>
</div>
);
}
ChartContainer.propTypes = {
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
legendRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.any }),
]).isRequired,
children: PropTypes.node.isRequired,
errorMsg: PropTypes.string,
};
export function ChartError(props) {
if(props.message === null) {
return <></>;
}
return (
<div className="pg-panel-error pg-panel-message" role="alert">{props.message}</div>
);
}
ChartError.propTypes = {
message: PropTypes.string,
};
export function DashboardRow({children}) {
return (
<div className="row dashboard-row">{children}</div>
);
}
DashboardRow.propTypes = {
children: PropTypes.node.isRequired,
};
export function DashboardRowCol({breakpoint, parts, children}) {
return (
<div className={`col-${breakpoint}-${parts}`}>{children}</div>
);
}
DashboardRowCol.propTypes = {
breakpoint: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']).isRequired,
parts: PropTypes.number.isRequired,
children: PropTypes.node.isRequired,
};

View File

@@ -35,8 +35,22 @@
color: $color-fg;
}
.dashboard-graph {
& .legend {
font-size: $tree-font-size;
& .legend-value {
font-weight: normal;
margin-left: 0.25rem;
& .legend-label {
margin-left: 0.25rem;
}
}
}
}
.dashboard-graph-body {
padding: 0.25rem 0.5rem;
height: 175px;
& .flotr-labels {
color: $color-fg !important;

View File

@@ -1,60 +1,5 @@
<div class="container-fluid dashboard-container negative-space">
<div id="dashboard-graphs" class="dashboard-hidden">
<div class="row dashboard-row">
<div class="col-md-6 col-12">
<div class="card" role="object-document" tabindex="0" aria-labelledby="database-session-graph">
<div class="card-header" id="database-session-graph">
{{ _('Database sessions') }}
</div>
<div class="card-body dashboard-graph-body">
<div id="graph-sessions" class="graph-container"></div>
</div>
</div>
</div>
<div class="col-md-6 col-12">
<div class="card" role="object-document" tabindex="0" aria-labelledby="transactions-per-second-graph">
<div class="card-header" id="transactions-per-second-graph">
{{ _('Transactions per second') }}
</div>
<div class="card-body dashboard-graph-body">
<div id="graph-tps" class="graph-container"></div>
</div>
</div>
</div>
</div>
<div class="row dashboard-row">
<div class="col-md-4 col-12">
<div class="card" role="object-document" tabindex="0" aria-labelledby="tuples-in-graph">
<div class="card-header" id="tuples-in-graph">
{{ _('Tuples in') }}
</div>
<div class="card-body dashboard-graph-body">
<div id="graph-ti" class="graph-container"></div>
</div>
</div>
</div>
<div class="col-md-4 col-12">
<div class="card" role="object-document" tabindex="0" aria-labelledby="tuples-out-graph">
<div class="card-header" id="tuples-out-graph">
{{ _('Tuples out') }}
</div>
<div class="card-body dashboard-graph-body">
<div id="graph-to" class="graph-container"></div>
</div>
</div>
</div>
<div class="col-md-4 col-12">
<div class="card" role="object-document" tabindex="0" aria-labelledby="block-io-graph">
<div class="card-header" id="block-io-graph">
{{ _('Block I/O') }}
</div>
<div class="card-body dashboard-graph-body">
<div id="graph-bio" class="graph-container"></div>
</div>
</div>
</div>
</div>
</div>
<div id="dashboard-graphs"></div>
<div id="dashboard-activity" class="card dashboard-row dashboard-hidden">
<div class="card-header">
<span id="dashboard-activity-header">{{ _('Server activity') }}</span>

View File

@@ -1,59 +1,5 @@
<div class="container-fluid dashboard-container negative-space">
<div id="dashboard-graphs" class="dashboard-hidden">
<div class="row dashboard-row">
<div class="col-md-6 col-12">
<div class="card" role="object-document" tabindex="0" aria-labelledby="server-sessions-graph">
<div class="card-header" id="server-sessions-graph">
{{ _('Server sessions') }}
</div>
<div class="card-body dashboard-graph-body">
<div id="graph-sessions" class="graph-container"></div>
</div>
</div>
</div>
<div class="col-md-6 col-12">
<div class="card" role="object-document" tabindex="0" aria-labelledby="transactions-per-second-graph">
<div class="card-header" id="transactions-per-second-graph">
{{ _('Transactions per second') }}
</div>
<div class="card-body dashboard-graph-body">
<div id="graph-tps" class="graph-container"></div>
</div>
</div>
</div>
</div>
<div class="row dashboard-row">
<div class="col-md-4 col-12">
<div class="card" role="object-document" tabindex="0" aria-labelledby="tuples-in-graph">
<div class="card-header" id="tuples-in-graph">
{{ _('Tuples in') }}
</div>
<div class="card-body dashboard-graph-body">
<div id="graph-ti" class="graph-container"></div>
</div>
</div>
</div>
<div class="col-md-4 col-12">
<div class="card" role="object-document" tabindex="0" aria-labelledby="tuples-out-graph">
<div class="card-header" id="tuples-out-graph">
{{ _('Tuples out') }}
</div>
<div class="card-body dashboard-graph-body">
<div id="graph-to" class="graph-container"></div>
</div>
</div>
</div>
<div class="col-md-4 col-12">
<div class="card" role="object-document" tabindex="0" aria-labelledby="block-o-graph">
<div class="card-header" id="block-o-graph">
{{ _('Block I/O') }}
</div>
<div class="card-body dashboard-graph-body">
<div id="graph-bio" class="graph-container"></div>
</div>
</div>
</div>
</div>
<div id="dashboard-graphs">
</div>
<div id="dashboard-activity" class="card dashboard-row dashboard-hidden">
<div class="card-header">

View File

@@ -0,0 +1,95 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useEffect } from 'react';
import Chart from 'chart.js';
import PropTypes from 'prop-types';
const defaultOptions = {
responsive: true,
maintainAspectRatio: false,
elements: {
line: {
tension: 0,
borderWidth: 2,
fill: false,
},
},
layout: {
padding: 8,
},
hover: {
animationDuration:0,
},
};
export default function BaseChart({type='line', id, options, data, redraw=false, ...props}) {
const chartRef = React.useRef();
const chartObj = React.useRef();
let optionsMerged = Chart.helpers.configMerge(defaultOptions, options);
const initChart = function() {
let chartContext = chartRef.current.getContext('2d');
chartObj.current = new Chart(chartContext, {
type: type,
data: data,
options: optionsMerged,
});
props.onInit && props.onInit(chartObj.current);
}
const destroyChart = function() {
chartObj.current && chartObj.current.destroy();
}
useEffect(()=>{
initChart();
return destroyChart;
}, []);
useEffect(()=>{
if(typeof(chartObj.current) != 'undefined') {
chartObj.current.data = data;
for(let i=0; i<chartObj.current.data.datasets.length; i++) {
chartObj.current.data.datasets[i] = {
...chartObj.current.data.datasets[i],
...data.datasets[i],
};
}
chartObj.current.options = optionsMerged;
chartObj.current.update(props.updateOptions || {});
props.onUpdate && props.onUpdate(chartObj.current);
}
}, [data, options]);
useEffect(()=>{
if(redraw) {
destroyChart();
initChart();
}
}, [redraw])
return (
<canvas id={id} ref={chartRef}></canvas>
);
}
BaseChart.propTypes = {
type: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
options: PropTypes.object,
updateOptions: PropTypes.object,
onInit: PropTypes.func,
onUpdate: PropTypes.func,
};
export function LineChart(props) {
return (
<BaseChart {...props} type='line'/>
);
}

View File

@@ -0,0 +1,29 @@
import {useRef, useEffect} from 'react';
/* React hook for setInterval */
export function useInterval(callback, delay) {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
function tick() {
savedCallback.current();
}
if(delay > -1) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
export function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}

View File

@@ -7,6 +7,7 @@ $theme-colors: (
/* Certain variables are required in JS directly */
:root {
--color-fg: #{$color-fg};
--border-color: #{$border-color};
}

View File

@@ -1,100 +0,0 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
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(
'<div id="charting-test-container"></div>'
);
chartDiv = $('#charting-test-container')[0];
chartObj = new Chart(chartDiv, options);
});
it('Return the correct container', ()=>{
expect(chartObj.getContainer()).toEqual(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 other data returns undefined for not set', ()=>{
expect(chartObj.getOtherData('some_val_not_set')).toEqual(undefined);
});
it('Check if isVisible returns correct', ()=>{
let dimSpy = spyOn(chartObj, 'getContainerDimensions');
dimSpy.and.returnValue({
height: 1, width: 1,
});
expect(chartObj.isVisible()).toEqual(true);
dimSpy.and.stub();
dimSpy.and.returnValue({
height: 0, width: 0,
});
expect(chartObj.isVisible()).toEqual(false);
});
it('Check if isInPage returns correct', ()=>{
expect(chartObj.isInPage()).toEqual(true);
$('body').find('#charting-test-container').remove();
expect(chartObj.isInPage()).toEqual(false);
});
afterEach(()=>{
$('body').find('#charting-test-container').remove();
});
});

View File

@@ -0,0 +1,169 @@
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import {mount} from 'enzyme';
import '../helper/enzyme.helper';
import Graphs, {GraphsWrapper} from '../../../pgadmin/dashboard/static/js/Graphs';
import {X_AXIS_LENGTH, POINT_SIZE, transformData, legendCallback,
getStatsUrl, statsReducer} from '../../../pgadmin/dashboard/static/js/Graphs';
describe('Graphs.js', ()=>{
it('transformData', ()=>{
expect(transformData({'Label1': [], 'Label2': []}, 1)).toEqual({
labels: [...Array(X_AXIS_LENGTH).keys()],
datasets: [{
label: 'Label1',
data: [],
borderColor: '#00BCD4',
backgroundColor: '#00BCD4',
pointHitRadius: POINT_SIZE,
},{
label: 'Label2',
data: [],
borderColor: '#9CCC65',
backgroundColor: '#9CCC65',
pointHitRadius: POINT_SIZE,
}],
refreshRate: 1,
});
});
it('legendCallback', ()=>{
expect(legendCallback({
id: 1,
data: {
datasets: [{
label: 'Label1',
backgroundColor: '#00BCD4',
},{
label: 'Label2',
backgroundColor: '#9CCC65',
}],
},
})).toEqual([
'<div class="1-legend d-flex">',
'<div class="legend-value"><span style="background-color:#00BCD4">&nbsp;&nbsp;&nbsp;&nbsp;</span>',
'<span class="legend-label">Label1</span>',
'</div>',
'<div class="legend-value"><span style="background-color:#9CCC65">&nbsp;&nbsp;&nbsp;&nbsp;</span>',
'<span class="legend-label">Label2</span>',
'</div>',
'</div>',
].join(''));
});
describe('getStatsUrl', ()=>{
it('for server', ()=>{
expect(getStatsUrl(432, -1, ['chart1'])).toEqual('/dashboard/dashboard_stats/432?chart_names=chart1');
});
it('for database', ()=>{
expect(getStatsUrl(432, 123, ['chart1'])).toEqual('/dashboard/dashboard_stats/432/123?chart_names=chart1');
});
it('for multiple graphs', ()=>{
expect(getStatsUrl(432, 123, ['chart1', 'chart2'])).toEqual('/dashboard/dashboard_stats/432/123?chart_names=chart1,chart2');
});
});
describe('statsReducer', ()=>{
it('with incoming no counter', ()=>{
let state = {
'Label1': [], 'Label2': [],
};
let action = {
incoming: {
'Label1': 1, 'Label2': 2,
},
};
let newState = {
'Label1': [1], 'Label2': [2],
};
state = statsReducer(state, action);
expect(state).toEqual(newState);
});
it('with incoming with counter', ()=>{
let state = {
'Label1': [1], 'Label2': [2],
};
let action = {
incoming: {
'Label1': 1, 'Label2': 3,
},
counter: true,
counterData: {'Label1': 1, 'Label2': 2},
};
let newState = {
'Label1': [0, 1], 'Label2': [1, 2],
};
state = statsReducer(state, action);
expect(state).toEqual(newState);
});
it('with reset', ()=>{
let state = {
'Label1': [0, 1], 'Label2': [1, 2],
};
let action = {
reset: {
'Label1': [2], 'Label2': [2],
},
};
let newState = {
'Label1': [2], 'Label2': [2],
};
state = statsReducer(state, action);
expect(state).toEqual(newState);
});
});
describe('<Graphs /> component', ()=>{
let graphComp = null;
let sid = 1;
let did = 1;
beforeEach(()=>{
jasmineEnzyme();
let dashboardPref = {
session_stats_refresh: 1,
tps_stats_refresh: 1,
ti_stats_refresh: 1,
to_stats_refresh: 1,
bio_stats_refresh: 1,
show_graphs: true,
graph_data_points: true,
graph_mouse_track: true,
};
graphComp = mount(<Graphs preferences={dashboardPref} sid={sid} did={did} enablePoll={false} pageVisible={true} />);
});
it('GraphsWrapper is rendered', (done)=>{
let found = graphComp.find(GraphsWrapper);
expect(found.length).toBe(1);
done();
});
it('pollDelay is set', (done)=>{
let found = graphComp.find('[data-testid="graph-poll-delay"]');
expect(found).toHaveClassName('d-none');
expect(found).toHaveText('1000');
done();
});
it('pollDelay on preference update', (done)=>{
let dashboardPref = {
session_stats_refresh: 5,
tps_stats_refresh: 10,
ti_stats_refresh: 5,
to_stats_refresh: 10,
bio_stats_refresh: 10,
show_graphs: true,
graph_data_points: true,
graph_mouse_track: true,
};
graphComp.setProps({preferences: dashboardPref});
let found = graphComp.find('[data-testid="graph-poll-delay"]');
expect(found).toHaveText('5000');
done();
});
});
});

View File

@@ -0,0 +1,90 @@
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import {mount} from 'enzyme';
import '../helper/enzyme.helper';
import {GraphsWrapper, X_AXIS_LENGTH, POINT_SIZE} from '../../../pgadmin/dashboard/static/js/Graphs';
describe('<GraphsWrapper /> component', ()=>{
let graphComp = null;
let defaultStats = {
labels: [...Array(X_AXIS_LENGTH).keys()],
datasets: [{
label: 'Label1',
data: [],
borderColor: '#00BCD4',
backgroundColor: '#00BCD4',
pointHitRadius: POINT_SIZE,
},{
label: 'Label2',
data: [],
borderColor: '#9CCC65',
backgroundColor: '#9CCC65',
pointHitRadius: POINT_SIZE,
}],
refreshRate: 1,
};
beforeEach(()=>{
jasmineEnzyme();
graphComp = mount(<GraphsWrapper sessionStats={defaultStats}
tpsStats={defaultStats}
tiStats={defaultStats}
toStats={defaultStats}
bioStats={defaultStats}
errorMsg={null}
showTooltip={true}
showDataPoints={true}
isDatabase={false} />);
});
it('graph containers are rendered', (done)=>{
let found = graphComp.find('.card.dashboard-graph');
expect(found.length).toBe(5);
done();
});
it('graph headers are correct', (done)=>{
let found = graphComp.find('.card.dashboard-graph');
expect(found.at(0)).toIncludeText('Server sessions');
expect(found.at(1)).toIncludeText('Transactions per second');
expect(found.at(2)).toIncludeText('Tuples in');
expect(found.at(3)).toIncludeText('Tuples out');
expect(found.at(4)).toIncludeText('Block I/O');
done();
});
it('graph headers when database', (done)=>{
let found = graphComp.find('.card.dashboard-graph');
graphComp.setProps({isDatabase: true});
expect(found.at(0)).toIncludeText('Database sessions');
done();
});
it('graph body has the canvas', (done)=>{
let found = graphComp.find('.card.dashboard-graph .dashboard-graph-body canvas');
expect(found.at(0).length).toBe(1);
expect(found.at(1).length).toBe(1);
expect(found.at(2).length).toBe(1);
expect(found.at(3).length).toBe(1);
expect(found.at(4).length).toBe(1);
done();
});
it('graph body shows the error', (done)=>{
graphComp.setProps({errorMsg: 'Some error occurred'});
let found = graphComp.find('.card.dashboard-graph .dashboard-graph-body .chart-wrapper');
expect(found.at(0)).toHaveClassName('d-none');
expect(found.at(1)).toHaveClassName('d-none');
expect(found.at(2)).toHaveClassName('d-none');
expect(found.at(3)).toHaveClassName('d-none');
expect(found.at(4)).toHaveClassName('d-none');
found = graphComp.find('.card.dashboard-graph .dashboard-graph-body .pg-panel-error.pg-panel-message');
expect(found.at(0)).toIncludeText('Some error occurred');
expect(found.at(1)).toIncludeText('Some error occurred');
expect(found.at(2)).toIncludeText('Some error occurred');
expect(found.at(3)).toIncludeText('Some error occurred');
expect(found.at(4)).toIncludeText('Some error occurred');
done();
});
});

View File

@@ -21,5 +21,6 @@ define(function () {
'datagrid.panel': '/panel/<int:trans_id>',
'search_objects.types': '/search_objects/types/<int:sid>/<int:did>',
'search_objects.search': '/search_objects/search/<int:sid>/<int:did>',
'dashboard.dashboard_stats': '/dashboard/dashboard_stats',
};
});

View File

@@ -0,0 +1,4 @@
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({adapter: new Adapter()});

View File

@@ -309,11 +309,6 @@ var getThemeWebpackConfig = function(theme_name) {
// Module and Rules: https://webpack.js.org/configuration/module/
// Loaders: https://webpack.js.org/loaders/
//
// imports-loader: it adds dependent modules(use:imports-loader?module1)
// at the beginning of module it is dependency of like:
// var jQuery = require('jquery'); var browser = require('pgadmin.browser')
// It solves number of problems
// Ref: http:/github.com/webpack-contrib/imports-loader/
rules: themeCssRules(theme_name),
},
resolve: {
@@ -393,12 +388,13 @@ module.exports = [{
// It solves number of problems
// Ref: http:/github.com/webpack-contrib/imports-loader/
rules: [{
test: /\.js$/,
test: /\.jsx?$/,
exclude: [/node_modules/, /vendor/],
use: {
loader: 'babel-loader',
options: {
presets: [['@babel/preset-env', {'modules': 'commonjs', 'useBuiltIns': 'usage', 'corejs': 3}]],
presets: [['@babel/preset-env', {'modules': 'commonjs', 'useBuiltIns': 'usage', 'corejs': 3}], '@babel/preset-react'],
plugins: ['@babel/plugin-proposal-class-properties'],
},
},
}, {
@@ -420,6 +416,11 @@ module.exports = [{
query: webpackShimConfig,
include: path.join(__dirname, '/pgadmin/browser'),
}, {
// imports-loader: it adds dependent modules(use:imports-loader?module1)
// at the beginning of module it is dependency of like:
// var jQuery = require('jquery'); var browser = require('pgadmin.browser')
// It solves number of problems
// Ref: http:/github.com/webpack-contrib/imports-loader/
test: require.resolve('./pgadmin/tools/datagrid/static/js/datagrid'),
use: {
loader: 'imports-loader?' +
@@ -517,7 +518,7 @@ module.exports = [{
resolve: {
alias: webpackShimConfig.resolveAlias,
modules: ['node_modules', '.'],
extensions: ['.js'],
extensions: ['.js', '.jsx'],
unsafeCache: true,
},
// Watch mode Configuration: After initial build, webpack will watch for

View File

@@ -82,9 +82,6 @@ var webpackShimConfig = {
'deps': ['jquery', 'jquery.ui', 'jquery.event.drag'],
'exports': 'Slick',
},
'flotr2': {
deps: ['bean'],
},
'alertify': {
'exports': 'alertify',
},
@@ -141,8 +138,6 @@ var webpackShimConfig = {
'moment': path.join(__dirname, './node_modules/moment/moment'),
'jquery.event.drag': path.join(__dirname, './node_modules/slickgrid/lib/jquery.event.drag-2.3.0'),
'jquery.ui': path.join(__dirname, './node_modules/slickgrid/lib/jquery-ui-1.11.3'),
'flotr2': path.join(__dirname, './node_modules/flotr2/flotr2.amd'),
'bean': path.join(__dirname, './node_modules/flotr2/lib/bean'),
'jqueryui.position': path.join(__dirname, './node_modules/jquery-contextmenu/dist/jquery.ui.position'),
'jquery.contextmenu': path.join(__dirname, './node_modules/jquery-contextmenu/dist/jquery.contextMenu'),
'dropzone': path.join(__dirname, './node_modules/dropzone/dist/dropzone'),

View File

@@ -31,12 +31,13 @@ module.exports = {
module: {
rules: [
{
test: /\.js$/,
test: /\.jsx?$/,
exclude: [/node_modules/, /vendor/],
use: {
loader: 'babel-loader',
options: {
presets: [['@babel/preset-env', {'modules': 'commonjs', 'useBuiltIns': 'usage', 'corejs': 3}]],
presets: [['@babel/preset-env', {'modules': 'commonjs', 'useBuiltIns': 'usage', 'corejs': 3}], '@babel/preset-react'],
plugins: ['@babel/plugin-proposal-class-properties'],
sourceMap: 'inline',
},
},
@@ -68,7 +69,7 @@ module.exports = {
},
resolve: {
extensions: ['.js'],
extensions: ['.js', '.jsx'],
alias: {
'top': path.join(__dirname, './pgadmin'),
'jquery': path.join(__dirname, './node_modules/jquery/dist/jquery'),
@@ -90,8 +91,6 @@ 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'),
'moment': path.join(__dirname, './node_modules/moment/moment'),
'browser': path.resolve(__dirname, 'pgadmin/browser/static/js'),
'pgadmin': sourcesDir + '/js/pgadmin',

File diff suppressed because it is too large Load Diff