Add live transaction stats card with history chart (#11813)
This commit is contained in:
271
explorer/src/components/TpsCard.tsx
Normal file
271
explorer/src/components/TpsCard.tsx
Normal 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=","
|
||||
/>
|
||||
);
|
||||
}
|
@@ -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=","
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -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) => {
|
||||
|
@@ -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:'';
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user