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

@@ -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:'';
}
}