From 6cb0b1fa1e9281981ca381f3c651481b70892a0b Mon Sep 17 00:00:00 2001 From: Dave Page Date: Wed, 19 Aug 2026 13:20:09 +0100 Subject: [PATCH 1/3] fix: normalise dashboard TPS graph by the configured refresh interval The Transactions per second chart plotted the raw xact_commit/xact_rollback delta between two polls without dividing by the elapsed time, so the value was only correct when the refresh interval was 1 second; at any other interval it showed transactions per interval instead of per second. Closes #10273 --- web/pgadmin/dashboard/static/js/Graphs.jsx | 17 ++++++++++++++--- .../javascript/dashboard/graphs_spec.js | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/web/pgadmin/dashboard/static/js/Graphs.jsx b/web/pgadmin/dashboard/static/js/Graphs.jsx index 7f2eae22825..aed2ea25f76 100644 --- a/web/pgadmin/dashboard/static/js/Graphs.jsx +++ b/web/pgadmin/dashboard/static/js/Graphs.jsx @@ -64,16 +64,27 @@ export function statsReducer(state, action) { action.counterData = action.incoming; } + /* When the counter represents a rate (e.g. transactions per second), + * the raw delta between two polls must be normalised by the number of + * seconds elapsed between them, otherwise it only reads correctly when + * the refresh interval happens to be 1 second. + */ + let rate = action.rate || 1; + let newState = {}; Object.keys(action.incoming).forEach(label => { + let value = action.incoming[label]; + if(action.counter) { + value = (action.incoming[label] - action.counterData[label]) / rate; + } if(state[label]) { newState[label] = [ - action.counter ? action.incoming[label] - action.counterData[label] : action.incoming[label], + value, ...state[label].slice(0, X_AXIS_LENGTH-1), ]; } else { newState[label] = [ - action.counter ? action.incoming[label] - action.counterData[label] : action.incoming[label], + value, ]; } }); @@ -169,7 +180,7 @@ export default function Graphs({preferences, sid, did, pageVisible, enablePoll=t let data = resp.data; setErrorMsg(null); sessionStatsReduce({incoming: data['session_stats']}); - tpsStatsReduce({incoming: data['tps_stats'], counter: true, counterData: counterData['tps_stats']}); + tpsStatsReduce({incoming: data['tps_stats'], counter: true, counterData: counterData['tps_stats'], rate: preferences['tps_stats_refresh']}); 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']}); diff --git a/web/regression/javascript/dashboard/graphs_spec.js b/web/regression/javascript/dashboard/graphs_spec.js index d02cd3488fa..6e2aa4a5b0d 100644 --- a/web/regression/javascript/dashboard/graphs_spec.js +++ b/web/regression/javascript/dashboard/graphs_spec.js @@ -72,6 +72,25 @@ describe('Graphs.js', ()=>{ expect(state).toEqual(newState); }); + it('with incoming with counter and rate', ()=>{ + let state = { + 'Label1': [1], 'Label2': [2], + }; + let action = { + incoming: { + 'Label1': 11, 'Label2': 23, + }, + counter: true, + counterData: {'Label1': 1, 'Label2': 3}, + rate: 5, + }; + let newState = { + 'Label1': [2, 1], 'Label2': [4, 2], + }; + state = statsReducer(state, action); + expect(state).toEqual(newState); + }); + it('with reset', ()=>{ let state = { 'Label1': [0, 1], 'Label2': [1, 2], From a7d1a6fafd3ada11cd4e99266635358f03f1bdb2 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Wed, 19 Aug 2026 13:30:31 +0100 Subject: [PATCH 2/3] fix: reset TPS counter baseline when the refresh interval changes Changing the tps_stats_refresh preference reset the displayed TPS history but kept the previous absolute counter reading, so the next delta was computed against a stale baseline whilst being divided by the new interval, mis-scaling the first post-change data point. Addresses CodeRabbit review on #10324 (issue #10273). --- web/pgadmin/dashboard/static/js/Graphs.jsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/pgadmin/dashboard/static/js/Graphs.jsx b/web/pgadmin/dashboard/static/js/Graphs.jsx index aed2ea25f76..30175820c30 100644 --- a/web/pgadmin/dashboard/static/js/Graphs.jsx +++ b/web/pgadmin/dashboard/static/js/Graphs.jsx @@ -125,6 +125,14 @@ export default function Graphs({preferences, sid, did, pageVisible, enablePoll=t } if(prevPrefernces['tps_stats_refresh'] != preferences['tps_stats_refresh']) { tpsStatsReduce({reset:chartsDefault['tps_stats']}); + /* The rate divisor is changing, so the previous counter baseline + * can no longer be used to compute the next delta. + */ + setCounterData((prevCounterData)=>{ + const nextCounterData = {...prevCounterData}; + delete nextCounterData['tps_stats']; + return nextCounterData; + }); calcPollDelay = true; } if(prevPrefernces['ti_stats_refresh'] != preferences['ti_stats_refresh']) { From 30b9ceede87bcf0d08dec638cf6715639b99e941 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Wed, 23 Sep 2026 13:40:28 +0100 Subject: [PATCH 3/3] fix: normalise dashboard TPS by the measured time between samples The configured tps_stats_refresh value is the requested interval, not the time actually elapsed between two counter samples, so a poll delayed by browser timer throttling overstated TPS. Each TPS sample is now time-stamped when its request is sent and the delta is divided by the measured elapsed seconds. Because the baseline carries its own timestamp, a change of refresh interval, or a response from a request made before that change, no longer mis-scales the next value, so the baseline reset on preference change is no longer needed. Adds a component-level test driving the poll path with a 5 second refresh and a delayed poll. Addresses CodeRabbit review on #10324 (issue #10273). --- web/pgadmin/dashboard/static/js/Graphs.jsx | 24 +++--- .../javascript/dashboard/graphs_spec.js | 80 ++++++++++++++++++- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/web/pgadmin/dashboard/static/js/Graphs.jsx b/web/pgadmin/dashboard/static/js/Graphs.jsx index 30175820c30..53d23099acc 100644 --- a/web/pgadmin/dashboard/static/js/Graphs.jsx +++ b/web/pgadmin/dashboard/static/js/Graphs.jsx @@ -69,13 +69,13 @@ export function statsReducer(state, action) { * seconds elapsed between them, otherwise it only reads correctly when * the refresh interval happens to be 1 second. */ - let rate = action.rate || 1; + let elapsed = action.elapsed > 0 ? action.elapsed : 1; let newState = {}; Object.keys(action.incoming).forEach(label => { let value = action.incoming[label]; if(action.counter) { - value = (action.incoming[label] - action.counterData[label]) / rate; + value = (action.incoming[label] - action.counterData[label]) / elapsed; } if(state[label]) { newState[label] = [ @@ -125,14 +125,6 @@ export default function Graphs({preferences, sid, did, pageVisible, enablePoll=t } if(prevPrefernces['tps_stats_refresh'] != preferences['tps_stats_refresh']) { tpsStatsReduce({reset:chartsDefault['tps_stats']}); - /* The rate divisor is changing, so the previous counter baseline - * can no longer be used to compute the next delta. - */ - setCounterData((prevCounterData)=>{ - const nextCounterData = {...prevCounterData}; - delete nextCounterData['tps_stats']; - return nextCounterData; - }); calcPollDelay = true; } if(prevPrefernces['ti_stats_refresh'] != preferences['ti_stats_refresh']) { @@ -183,12 +175,21 @@ export default function Graphs({preferences, sid, did, pageVisible, enablePoll=t }); let path = getStatsUrl(sid, did, getFor); + /* Normalise TPS by the time actually measured between two samples + * rather than the configured refresh interval, as timers can be + * delayed or throttled by the browser. + */ + const sampledAt = Date.now(); 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'], rate: preferences['tps_stats_refresh']}); + tpsStatsReduce({ + incoming: data['tps_stats'], counter: true, + counterData: counterData['tps_stats'], + elapsed: (sampledAt - counterData['tps_stats_sampled_at']) / 1000, + }); 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']}); @@ -197,6 +198,7 @@ export default function Graphs({preferences, sid, did, pageVisible, enablePoll=t return { ...prevCounterData, ...data, + ...(data['tps_stats'] ? {'tps_stats_sampled_at': sampledAt} : {}), }; }); }) diff --git a/web/regression/javascript/dashboard/graphs_spec.js b/web/regression/javascript/dashboard/graphs_spec.js index 6e2aa4a5b0d..6ab4541c1d2 100644 --- a/web/regression/javascript/dashboard/graphs_spec.js +++ b/web/regression/javascript/dashboard/graphs_spec.js @@ -5,7 +5,20 @@ import { DATA_POINT_SIZE } from 'sources/chartjs'; import Graphs, { transformData, getStatsUrl, statsReducer} from '../../../pgadmin/dashboard/static/js/Graphs'; import { withTheme } from '../fake_theme'; -import { render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; + +/* Render each chart's data points as text so the plotted values can be + * checked without drawing the charts. + */ +jest.mock('../../../pgadmin/dashboard/static/js/components/ChartContainer', ()=>{ + const React = require('react'); + const MockChartContainer = ({id, datasets})=>React.createElement( + 'div', {'data-testid': id}, JSON.stringify(datasets.map((d)=>d.data)) + ); + return MockChartContainer; +}); describe('Graphs.js', ()=>{ it('transformData', ()=>{ @@ -72,7 +85,7 @@ describe('Graphs.js', ()=>{ expect(state).toEqual(newState); }); - it('with incoming with counter and rate', ()=>{ + it('with incoming with counter and elapsed time', ()=>{ let state = { 'Label1': [1], 'Label2': [2], }; @@ -82,7 +95,7 @@ describe('Graphs.js', ()=>{ }, counter: true, counterData: {'Label1': 1, 'Label2': 3}, - rate: 5, + elapsed: 5, }; let newState = { 'Label1': [2, 1], 'Label2': [4, 2], @@ -151,4 +164,65 @@ describe('Graphs.js', ()=>{ expect(found).toHaveTextContent('5000'); }); }); + + describe(' TPS polling', ()=>{ + let networkMock; + let ThemedGraphs = withTheme(Graphs); + let dashboardPref = { + session_stats_refresh: 5, + tps_stats_refresh: 5, + ti_stats_refresh: 5, + to_stats_refresh: 5, + bio_stats_refresh: 5, + show_graphs: true, + graph_data_points: true, + graph_mouse_track: true, + graph_line_border_width: 2 + }; + + let commitCounts; + + beforeEach(()=>{ + jest.useFakeTimers(); + commitCounts = []; + networkMock = new MockAdapter(axios); + /* Only answer with TPS data when the poll asks for it. */ + networkMock.onGet(/\/dashboard\/dashboard_stats\//).reply((config)=>{ + if(config.url.includes('tps_stats')) { + return [200, {'tps_stats': {'Commits': commitCounts.shift()}}]; + } + return [200, {}]; + }); + }); + + afterEach(()=>{ + networkMock.restore(); + jest.useRealTimers(); + }); + + it('normalises TPS by the measured time between samples', async ()=>{ + commitCounts.push(100); + let graphComp; + await act(async ()=>{ + graphComp = render(); + }); + + /* 50 transactions over the configured 5 seconds is 10 per second. */ + commitCounts.push(150); + await act(async ()=>{ + await jest.advanceTimersByTimeAsync(5000); + }); + expect(graphComp.getByTestId('tps-graph')).toHaveTextContent('[[10,0]]'); + + /* A delayed poll: 100 transactions over 10 seconds is still 10 per + * second, not 20. + */ + jest.setSystemTime(Date.now() + 5000); + commitCounts.push(250); + await act(async ()=>{ + await jest.advanceTimersByTimeAsync(5000); + }); + expect(graphComp.getByTestId('tps-graph')).toHaveTextContent('[[10,10,0]]'); + }); + }); });