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