diff --git a/explorer/package-lock.json b/explorer/package-lock.json
index 6d2d3bf6a3..5a65bd82b3 100644
--- a/explorer/package-lock.json
+++ b/explorer/package-lock.json
@@ -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",
diff --git a/explorer/package.json b/explorer/package.json
index 57ec933954..5f483aae9f 100644
--- a/explorer/package.json
+++ b/explorer/package.json
@@ -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",
diff --git a/explorer/src/components/TpsCard.tsx b/explorer/src/components/TpsCard.tsx
new file mode 100644
index 0000000000..9fdbbedd18
--- /dev/null
+++ b/explorer/src/components/TpsCard.tsx
@@ -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 (
+
+
+
Live Transaction Stats
+
+
+
+ );
+}
+
+function TpsCardBody() {
+ const performanceInfo = usePerformanceInfo();
+ const { cluster } = useCluster();
+
+ const statsAvailable =
+ cluster === Cluster.MainnetBeta || cluster === Cluster.Testnet;
+ if (!statsAvailable) {
+ return (
+
+
+ Stats are not available for this cluster
+
+
+ );
+ }
+
+ if (!performanceInfo) {
+ return (
+
+
+ Loading
+
+ );
+ }
+
+ return ;
+}
+
+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 = ``;
+ 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 = `${value} TPS
`;
+ innerHtml += `${label}
`;
+ 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("short");
+ const averageTps = Math.round(avgTps).toLocaleString("en-US");
+ const transactionCount = ;
+ 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 (
+ <>
+
+
+ Transaction count |
+ {transactionCount} |
+
+
+ Transactions per second (TPS) |
+ {averageTps} |
+
+
+
+
+
+
+
+
+
TPS history
+
+
+ {SERIES.map((key) => (
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/explorer/src/pages/ClusterStatsPage.tsx b/explorer/src/pages/ClusterStatsPage.tsx
index b351e0497e..ca71f61c0c 100644
--- a/explorer/src/pages/ClusterStatsPage.tsx
+++ b/explorer/src/pages/ClusterStatsPage.tsx
@@ -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() {
+
);
}
@@ -71,8 +70,6 @@ function StatsCardBody() {
slotsInEpoch - slotIndex,
hourlyBlockTime
);
- const averageTps = Math.round(performanceInfo.avgTPS);
- const transactionCount = ;
const blockHeight = epochInfo.blockHeight.toLocaleString("en-US");
const currentSlot = epochInfo.absoluteSlot.toLocaleString("en-US");
@@ -102,56 +99,6 @@ function StatsCardBody() {
Epoch time remaining |
{epochTimeRemaining} |
-
- Transaction count |
- {transactionCount} |
-
-
- Transactions per second |
- {averageTps} |
-
);
}
-
-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 (
-
- );
-}
diff --git a/explorer/src/providers/stats/solanaBeach.tsx b/explorer/src/providers/stats/solanaBeach.tsx
index 914512ab8c..d3893290a2 100644
--- a/explorer/src/providers/stats/solanaBeach.tsx
+++ b/explorer/src/providers/stats/solanaBeach.tsx
@@ -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(
undefined
);
-export type PerformanceInfo = StructType;
+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(
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, 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) => {
diff --git a/explorer/src/scss/_solana.scss b/explorer/src/scss/_solana.scss
index 9c565395cc..d8d68bcbc3 100644
--- a/explorer/src/scss/_solana.scss
+++ b/explorer/src/scss/_solana.scss
@@ -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:'';
+ }
+}