Add live transaction stats card with history chart (#11813)

This commit is contained in:
Justin Starry
2020-08-25 03:08:02 +08:00
committed by GitHub
parent 9a366281d3
commit 40ca3ae796
6 changed files with 439 additions and 58 deletions

View File

@@ -3181,6 +3181,19 @@
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.12.tgz",
"integrity": "sha512-aN5IAC8QNtSUdQzxu7lGBgYAOuU1tmRU4c9dIq5OKGf/SBVjXo+ffM2wEjudAWbgpOhy60nLoAGH1xm8fpCKFQ=="
},
"@types/chart.js": {
"version": "2.9.23",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.23.tgz",
"integrity": "sha512-4QQNE/b+digosu3mnj4E7aNQGKnlpzXa9JvQYPtexpO7v9gnDeqwc1DxF8vLJWLDCNoO6hH0EgO8K/7PtJl8wg==",
"requires": {
"moment": "^2.10.2"
}
},
"@types/classnames": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.10.tgz",
"integrity": "sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ=="
},
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@@ -5447,6 +5460,32 @@
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
},
"chart.js": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.3.tgz",
"integrity": "sha512-+2jlOobSk52c1VU6fzkh3UwqHMdSlgH1xFv9FKMqHiNCpXsGPQa/+81AFa+i3jZ253Mq9aAycPwDjnn1XbRNNw==",
"requires": {
"chartjs-color": "^2.1.0",
"moment": "^2.10.2"
}
},
"chartjs-color": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
"requires": {
"chartjs-color-string": "^0.6.0",
"color-convert": "^1.9.3"
}
},
"chartjs-color-string": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
"requires": {
"color-name": "^1.0.0"
}
},
"check-error": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
@@ -5629,6 +5668,11 @@
}
}
},
"classnames": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
},
"clean-css": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz",
@@ -10886,6 +10930,11 @@
}
}
},
"moment": {
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz",
"integrity": "sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ=="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@@ -13180,6 +13229,22 @@
"semver": "^5.6.0"
}
},
"react-chartjs-2": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.10.0.tgz",
"integrity": "sha512-1MjWEkUn8LLFf6GVyYUOrruJTW3yVU5hlEJOwGj3MiokuC+jH/BahjWVGAMonbe9UYbEIUbd2Rn36iVlC0Hb7w==",
"requires": {
"lodash": "^4.17.19",
"prop-types": "^15.7.2"
},
"dependencies": {
"lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
}
}
},
"react-countup": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/react-countup/-/react-countup-4.3.3.tgz",

View File

@@ -12,6 +12,8 @@
"@types/bn.js": "^4.11.6",
"@types/bs58": "^4.0.1",
"@types/chai": "^4.2.12",
"@types/chart.js": "^2.9.23",
"@types/classnames": "^2.2.10",
"@types/jest": "^26.0.10",
"@types/node": "^14.6.0",
"@types/react": "^16.9.46",
@@ -23,11 +25,14 @@
"bootstrap": "^4.5.2",
"bs58": "^4.0.1",
"chai": "^4.2.0",
"chart.js": "^2.9.3",
"classnames": "2.2.6",
"humanize-duration-ts": "^2.1.1",
"node-sass": "^4.14.1",
"prettier": "^2.0.5",
"react": "^16.13.1",
"react-app-rewired": "^2.1.6",
"react-chartjs-2": "^2.10.0",
"react-countup": "^4.3.3",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",

View File

@@ -0,0 +1,271 @@
import React from "react";
import { Bar } from "react-chartjs-2";
import CountUp from "react-countup";
import {
usePerformanceInfo,
PERF_UPDATE_SEC,
PerformanceInfo,
} from "providers/stats/solanaBeach";
import classNames from "classnames";
import { TableCardBody } from "components/common/TableCardBody";
import { useCluster, Cluster } from "providers/cluster";
import { ChartOptions, ChartTooltipModel } from "chart.js";
export function TpsCard() {
return (
<div className="card">
<div className="card-header">
<h4 className="card-header-title">Live Transaction Stats</h4>
</div>
<TpsCardBody />
</div>
);
}
function TpsCardBody() {
const performanceInfo = usePerformanceInfo();
const { cluster } = useCluster();
const statsAvailable =
cluster === Cluster.MainnetBeta || cluster === Cluster.Testnet;
if (!statsAvailable) {
return (
<div className="card-body text-center">
<div className="text-muted">
Stats are not available for this cluster
</div>
</div>
);
}
if (!performanceInfo) {
return (
<div className="card-body text-center">
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</div>
);
}
return <TpsBarChart performanceInfo={performanceInfo} />;
}
type Series = "short" | "medium" | "long";
const SERIES: Series[] = ["short", "medium", "long"];
const SERIES_INFO = {
short: {
label: (index: number) => Math.floor(index / 4),
interval: "30m",
},
medium: {
label: (index: number) => index,
interval: "2h",
},
long: {
label: (index: number) => 3 * index,
interval: "6h",
},
};
const CUSTOM_TOOLTIP = function (this: any, tooltipModel: ChartTooltipModel) {
// Tooltip Element
let tooltipEl = document.getElementById("chartjs-tooltip");
// Create element on first render
if (!tooltipEl) {
tooltipEl = document.createElement("div");
tooltipEl.id = "chartjs-tooltip";
tooltipEl.innerHTML = `<div class="content"></div>`;
document.body.appendChild(tooltipEl);
}
// Hide if no tooltip
if (tooltipModel.opacity === 0) {
tooltipEl.style.opacity = "0";
return;
}
// Set Text
if (tooltipModel.body) {
const { label, value } = tooltipModel.dataPoints[0];
const tooltipContent = tooltipEl.querySelector("div");
if (tooltipContent) {
let innerHtml = `<div class="value">${value} TPS</div>`;
innerHtml += `<div class="label">${label}</div>`;
tooltipContent.innerHTML = innerHtml;
}
}
// Enable tooltip and set position
const canvas: Element = this._chart.canvas;
const position = canvas.getBoundingClientRect();
tooltipEl.style.opacity = "1";
tooltipEl.style.left =
position.left + window.pageXOffset + tooltipModel.caretX + "px";
tooltipEl.style.top =
position.top + window.pageYOffset + tooltipModel.caretY + "px";
};
const CHART_OPTIONS = (historyMaxTps: number): ChartOptions => {
return {
tooltips: {
intersect: false, // Show tooltip when cursor in between bars
enabled: false, // Hide default tooltip
custom: CUSTOM_TOOLTIP,
},
legend: {
display: false,
},
scales: {
xAxes: [
{
ticks: {
display: false,
},
gridLines: {
display: false,
},
},
],
yAxes: [
{
ticks: {
stepSize: 100,
fontSize: 10,
fontColor: "#EEE",
beginAtZero: true,
display: true,
suggestedMax: historyMaxTps,
},
gridLines: {
display: false,
},
},
],
},
animation: {
duration: 0, // general animation time
},
hover: {
animationDuration: 0, // duration of animations when hovering an item
},
responsiveAnimationDuration: 0, // animation duration after a resize
};
};
type TpsBarChartProps = { performanceInfo: PerformanceInfo };
function TpsBarChart({ performanceInfo }: TpsBarChartProps) {
const { perfHistory, avgTps, historyMaxTps } = performanceInfo;
const [series, setSeries] = React.useState<Series>("short");
const averageTps = Math.round(avgTps).toLocaleString("en-US");
const transactionCount = <AnimatedTransactionCount info={performanceInfo} />;
const seriesData = perfHistory[series];
const chartOptions = React.useMemo(() => CHART_OPTIONS(historyMaxTps), [
historyMaxTps,
]);
const seriesLength = seriesData.length;
const chartData: Chart.ChartData = {
labels: seriesData.map((val, i) => {
return `${SERIES_INFO[series].label(seriesLength - i)}min ago`;
}),
datasets: [
{
backgroundColor: "#00D192",
hoverBackgroundColor: "#00D192",
borderWidth: 0,
data: seriesData.map((val) => val || 0),
},
],
};
return (
<>
<TableCardBody>
<tr>
<td className="w-100">Transaction count</td>
<td className="text-lg-right text-monospace">{transactionCount} </td>
</tr>
<tr>
<td className="w-100">Transactions per second (TPS)</td>
<td className="text-lg-right text-monospace">{averageTps} </td>
</tr>
</TableCardBody>
<hr className="my-0" />
<div className="card-body py-3">
<div className="align-box-row align-items-start justify-content-between">
<div className="d-flex justify-content-between w-100">
<span className="mb-0 font-size-sm">TPS history</span>
<div className="font-size-sm">
{SERIES.map((key) => (
<button
key={key}
onClick={() => setSeries(key)}
className={classNames("btn btn-sm btn-white ml-2", {
active: series === key,
})}
>
{SERIES_INFO[key].interval}
</button>
))}
</div>
</div>
<div
id="perf-history"
className="mt-3 d-flex justify-content-end flex-row w-100"
>
<div className="w-100">
<Bar data={chartData} options={chartOptions} height={80} />
</div>
</div>
</div>
</div>
</>
);
}
function AnimatedTransactionCount({ info }: { info: PerformanceInfo }) {
const txCountRef = React.useRef(0);
const countUpRef = React.useRef({ start: 0, period: 0, lastUpdate: 0 });
const countUp = countUpRef.current;
const { transactionCount: txCount, avgTps } = info;
// Track last tx count to reset count up options
if (txCount !== txCountRef.current) {
if (countUp.lastUpdate > 0) {
// Since we overshoot below, calculate the elapsed value
// and start from there.
const elapsed = Date.now() - countUp.lastUpdate;
const elapsedPeriods = elapsed / (PERF_UPDATE_SEC * 1000);
countUp.start = countUp.start + elapsedPeriods * countUp.period;
countUp.period = txCount - countUp.start;
} else {
// Since this is the first tx count value, estimate the previous
// tx count in order to have a starting point for our animation
countUp.period = PERF_UPDATE_SEC * avgTps;
countUp.start = txCount - countUp.period;
}
countUp.lastUpdate = Date.now();
txCountRef.current = txCount;
}
// Overshoot the target tx count in case the next update is delayed
const COUNT_PERIODS = 3;
const countUpEnd = countUp.start + COUNT_PERIODS * countUp.period;
return (
<CountUp
start={countUp.start}
end={countUpEnd}
duration={PERF_UPDATE_SEC * COUNT_PERIODS}
delay={0}
useEasing={false}
preserveValue={true}
separator=","
/>
);
}

View File

@@ -1,16 +1,14 @@
import React from "react";
import CountUp from "react-countup";
import { TableCardBody } from "components/common/TableCardBody";
import {
useDashboardInfo,
usePerformanceInfo,
PERF_UPDATE_SEC,
useSetActive,
PerformanceInfo,
} from "providers/stats/solanaBeach";
import { slotsToHumanString } from "utils";
import { useCluster, Cluster } from "providers/cluster";
import { TpsCard } from "components/TpsCard";
export function ClusterStatsPage() {
return (
@@ -25,6 +23,7 @@ export function ClusterStatsPage() {
</div>
<StatsCardBody />
</div>
<TpsCard />
</div>
);
}
@@ -71,8 +70,6 @@ function StatsCardBody() {
slotsInEpoch - slotIndex,
hourlyBlockTime
);
const averageTps = Math.round(performanceInfo.avgTPS);
const transactionCount = <AnimatedTransactionCount info={performanceInfo} />;
const blockHeight = epochInfo.blockHeight.toLocaleString("en-US");
const currentSlot = epochInfo.absoluteSlot.toLocaleString("en-US");
@@ -102,56 +99,6 @@ function StatsCardBody() {
<td className="w-100">Epoch time remaining</td>
<td className="text-lg-right text-monospace">{epochTimeRemaining} </td>
</tr>
<tr>
<td className="w-100">Transaction count</td>
<td className="text-lg-right text-monospace">{transactionCount} </td>
</tr>
<tr>
<td className="w-100">Transactions per second</td>
<td className="text-lg-right text-monospace">{averageTps} </td>
</tr>
</TableCardBody>
);
}
function AnimatedTransactionCount({ info }: { info: PerformanceInfo }) {
const txCountRef = React.useRef(0);
const countUpRef = React.useRef({ start: 0, period: 0, lastUpdate: 0 });
const countUp = countUpRef.current;
const { totalTransactionCount: txCount, avgTPS } = info;
// Track last tx count to reset count up options
if (txCount !== txCountRef.current) {
if (countUp.lastUpdate > 0) {
// Since we overshoot below, calculate the elapsed value
// and start from there.
const elapsed = Date.now() - countUp.lastUpdate;
const elapsedPeriods = elapsed / (PERF_UPDATE_SEC * 1000);
countUp.start = countUp.start + elapsedPeriods * countUp.period;
countUp.period = txCount - countUp.start;
} else {
// Since this is the first tx count value, estimate the previous
// tx count in order to have a starting point for our animation
countUp.period = PERF_UPDATE_SEC * avgTPS;
countUp.start = txCount - countUp.period;
}
countUp.lastUpdate = Date.now();
txCountRef.current = txCount;
}
// Overshoot the target tx count in case the next update is delayed
const COUNT_PERIODS = 3;
const countUpEnd = countUp.start + COUNT_PERIODS * countUp.period;
return (
<CountUp
start={countUp.start}
end={countUpEnd}
duration={PERF_UPDATE_SEC * COUNT_PERIODS}
delay={0}
useEasing={false}
preserveValue={true}
separator=","
/>
);
}

View File

@@ -1,7 +1,7 @@
import React from "react";
import io from "socket.io-client";
import { pick, number, is, StructType } from "superstruct";
import { pick, array, nullable, number, is, StructType } from "superstruct";
import { useCluster, Cluster } from "providers/cluster";
const DashboardInfo = pick({
@@ -24,6 +24,11 @@ export const PERF_UPDATE_SEC = 5;
const PerformanceInfo = pick({
avgTPS: number(),
perfHistory: pick({
s: array(nullable(number())),
m: array(nullable(number())),
l: array(nullable(number())),
}),
totalTransactionCount: number(),
});
@@ -42,7 +47,17 @@ const DashboardContext = React.createContext<DashboardState | undefined>(
undefined
);
export type PerformanceInfo = StructType<typeof PerformanceInfo>;
export type PerformanceInfo = {
avgTps: number;
historyMaxTps: number;
perfHistory: {
short: (number | null)[];
medium: (number | null)[];
long: (number | null)[];
};
transactionCount: number;
};
type PerformanceState = { info: PerformanceInfo | undefined };
const PerformanceContext = React.createContext<PerformanceState | undefined>(
undefined
@@ -87,7 +102,39 @@ export function SolanaBeachProvider({ children }: Props) {
});
socket.on("performanceInfo", (data: any) => {
if (is(data, PerformanceInfo)) {
setPerformanceInfo(data);
const trimSeries = (series: (number | null)[]) => {
return series.slice(series.length - 51, series.length - 1);
};
const seriesMax = (series: (number | null)[]) => {
return series.reduce((max: number, next) => {
if (next === null) return max;
return Math.max(max, next);
}, 0);
};
const normalize = (series: Array<number | null>, seconds: number) => {
return series.map((next) => {
if (next === null) return next;
return Math.round(next / seconds);
});
};
const short = normalize(trimSeries(data.perfHistory.s), 15);
const medium = normalize(trimSeries(data.perfHistory.m), 60);
const long = normalize(trimSeries(data.perfHistory.l), 180);
const historyMaxTps = Math.max(
seriesMax(short),
seriesMax(medium),
seriesMax(long)
);
setPerformanceInfo({
avgTps: data.avgTPS,
historyMaxTps,
perfHistory: { short, medium, long },
transactionCount: data.totalTransactionCount,
});
}
});
socket.on("rootNotification", (data: any) => {

View File

@@ -198,3 +198,49 @@ h4.slot-pill {
width: 100% !important;
}
}
#chartjs-tooltip {
opacity: 1;
position: absolute;
-webkit-transition: all .1s ease;
transition: all .1s ease;
pointer-events: none;
-webkit-transform: translate(-50%, -105%);
transform: translate(-50%, -105%);
div.content {
font-size: 12px;
color: white;
margin: 0px;
background: rgba(0, 0, 0, 0.9);
border-radius: 4px;
text-align: center;
overflow: hidden;
}
div.label {
padding: 10px;
background: #111;
}
div.value {
padding: 10px;
background: black;
border-bottom: 1px solid #333;
}
&:after {
display: block;
position: relative;
width: 0;
height: 0;
left: 50%;
top: -1px;
border-right: 10px solid transparent;
border-top: 10px solid #111;
border-left: 10px solid transparent;
-webkit-transform: translate(-50%, 0);
transform: translate(-50%, 0);
content:'';
}
}