From f6a06826d828cbea54da8dc99b37611161711671 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 1 Mar 2022 10:50:48 -0800 Subject: [PATCH] feat(explorer): add Solana Ping to cluster stats page (#23239) * feat: add Solana Ping to explorer * fix: remove br tags in label --- explorer/src/components/SolanaPingCard.tsx | 239 ++++++++++++++++++ explorer/src/pages/ClusterStatsPage.tsx | 2 + .../providers/stats/SolanaPingProvider.tsx | 151 +++++++++++ explorer/src/providers/stats/index.tsx | 7 +- 4 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 explorer/src/components/SolanaPingCard.tsx create mode 100644 explorer/src/providers/stats/SolanaPingProvider.tsx diff --git a/explorer/src/components/SolanaPingCard.tsx b/explorer/src/components/SolanaPingCard.tsx new file mode 100644 index 0000000000..62fc4396d1 --- /dev/null +++ b/explorer/src/components/SolanaPingCard.tsx @@ -0,0 +1,239 @@ +import React from "react"; +import classNames from "classnames"; +import { + PingRollupInfo, + PingStatus, + useSolanaPingInfo, +} from "providers/stats/SolanaPingProvider"; +import { Bar } from "react-chartjs-2"; +import { ChartOptions, ChartTooltipModel } from "chart.js"; +import { Cluster, useCluster } from "providers/cluster"; + +export function SolanaPingCard() { + const { cluster } = useCluster(); + + if (cluster === Cluster.Custom) { + return null; + } + + return ( +
+
+

Solana Ping Stats

+
+ +
+ ); +} + +function PingBarBody() { + const pingInfo = useSolanaPingInfo(); + + if (pingInfo.status !== PingStatus.Ready) { + return ( + + ); + } + + return ; +} + +type StatsNotReadyProps = { error: boolean; retry?: Function }; +function StatsNotReady({ error, retry }: StatsNotReadyProps) { + if (error) { + return ( +
+ There was a problem loading solana ping stats.{" "} + {retry && ( + + )} +
+ ); + } + + return ( +
+ + Loading +
+ ); +} + +type Series = "short" | "medium" | "long"; +const SERIES: Series[] = ["short", "medium", "long"]; +const SERIES_INFO = { + short: { + label: (index: number) => index, + interval: "30m", + }, + medium: { + label: (index: number) => index * 4, + interval: "2h", + }, + long: { + label: (index: number) => index * 12, + 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} ms
`; + 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_OPTION: ChartOptions = { + 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, + }, + 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 +}; + +function PingBarChart({ pingInfo }: { pingInfo: PingRollupInfo }) { + const [series, setSeries] = React.useState("short"); + const seriesData = pingInfo[series] || []; + + const seriesLength = seriesData.length; + const chartData: Chart.ChartData = { + labels: seriesData.map((val, i) => { + return ` +

${val.confirmed} of ${val.submitted} confirmed

+ ${ + val.loss + ? `

${val.loss.toLocaleString(undefined, { + style: "percent", + minimumFractionDigits: 2, + })} loss

` + : "" + } + ${SERIES_INFO[series].label(seriesLength - i)}min ago + `; + }), + datasets: [ + { + backgroundColor: seriesData.map((val) => + val.loss ? "#fa62fc" : "#00D192" + ), + hoverBackgroundColor: seriesData.map((val) => + val.loss ? "#fa62fc" : "#00D192" + ), + borderWidth: 0, + data: seriesData.map((val) => val.mean || 0), + }, + ], + }; + + return ( +
+
+
+ Average Confirmation Time + +
+ {SERIES.map((key) => ( + + ))} +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/explorer/src/pages/ClusterStatsPage.tsx b/explorer/src/pages/ClusterStatsPage.tsx index 3503a9543d..99ddb6eecb 100644 --- a/explorer/src/pages/ClusterStatsPage.tsx +++ b/explorer/src/pages/ClusterStatsPage.tsx @@ -18,6 +18,7 @@ import { useVoteAccounts } from "providers/accounts/vote-accounts"; import { CoingeckoStatus, useCoinGecko } from "utils/coingecko"; import { Epoch } from "components/common/Epoch"; import { TimestampToggle } from "components/common/TimestampToggle"; +import { SolanaPingCard } from "components/SolanaPingCard"; const CLUSTER_STATS_TIMEOUT = 5000; @@ -36,6 +37,7 @@ export function ClusterStatsPage() { + ); } diff --git a/explorer/src/providers/stats/SolanaPingProvider.tsx b/explorer/src/providers/stats/SolanaPingProvider.tsx new file mode 100644 index 0000000000..bed3505a0c --- /dev/null +++ b/explorer/src/providers/stats/SolanaPingProvider.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import { Cluster, clusterSlug, useCluster } from "providers/cluster"; +import { fetch } from "cross-fetch"; + +const FETCH_PING_INTERVAL = 60 * 1000; + +function getPingUrl(cluster: Cluster) { + const slug = clusterSlug(cluster); + + if (slug === "custom") { + return undefined; + } + + return `https://ping.solana.com/${slug}/last6hours`; +} + +export type PingMetric = { + submitted: number; + confirmed: number; + loss: string; + mean_ms: number; + ts: string; + error: string; +}; + +export type PingInfo = { + submitted: number; + confirmed: number; + loss: number; + mean: number; + timestamp: Date; +}; + +export enum PingStatus { + Loading, + Ready, + Error, +} + +export type PingRollupInfo = { + status: PingStatus; + short?: PingInfo[]; + medium?: PingInfo[]; + long?: PingInfo[]; + retry?: Function; +}; + +const PingContext = React.createContext(undefined); + +type Props = { children: React.ReactNode }; + +function downsample(points: PingInfo[], bucketSize: number): PingInfo[] { + const buckets = []; + + for (let start = 0; start < points.length; start += bucketSize) { + const summary: PingInfo = { + submitted: 0, + confirmed: 0, + loss: 0, + mean: 0, + timestamp: points[start].timestamp, + }; + for (let i = 0; i < bucketSize; i++) { + summary.submitted += points[start + i].submitted; + summary.confirmed += points[start + i].confirmed; + summary.mean += points[start + i].mean; + } + summary.mean = Math.round(summary.mean / bucketSize); + summary.loss = (summary.submitted - summary.confirmed) / summary.submitted; + buckets.push(summary); + } + + return buckets; +} + +export function SolanaPingProvider({ children }: Props) { + const { cluster } = useCluster(); + const [rollup, setRollup] = React.useState({ + status: PingStatus.Loading, + }); + + React.useEffect(() => { + const url = getPingUrl(cluster); + + setRollup({ + status: PingStatus.Loading, + }); + + if (!url) { + return; + } + + const fetchPingMetrics = () => { + fetch(url) + .then((res) => { + return res.json(); + }) + .then((body: PingMetric[]) => { + const points = body + .map( + ({ submitted, confirmed, mean_ms, ts }: PingMetric) => { + return { + submitted, + confirmed, + loss: (submitted - confirmed) / submitted, + mean: mean_ms, + timestamp: new Date(ts), + }; + } + ) + .reverse(); + + const short = points.slice(-30); + const medium = downsample(points, 4).slice(-30); + const long = downsample(points, 12); + + setRollup({ + short, + medium, + long, + status: PingStatus.Ready, + }); + }) + .catch((error) => { + setRollup({ + status: PingStatus.Error, + retry: fetchPingMetrics, + }); + }); + }; + + const fetchPingInterval = setInterval( + fetchPingMetrics, + FETCH_PING_INTERVAL + ); + fetchPingMetrics(); + return () => { + clearInterval(fetchPingInterval); + }; + }, [cluster]); + + return {children}; +} + +export function useSolanaPingInfo() { + const context = React.useContext(PingContext); + if (!context) { + throw new Error(`useContext must be used within a StatsProvider`); + } + return context; +} diff --git a/explorer/src/providers/stats/index.tsx b/explorer/src/providers/stats/index.tsx index 56e20c9980..53fe3f853d 100644 --- a/explorer/src/providers/stats/index.tsx +++ b/explorer/src/providers/stats/index.tsx @@ -1,7 +1,12 @@ +import { SolanaPingProvider } from "providers/stats/SolanaPingProvider"; import React from "react"; import { SolanaClusterStatsProvider } from "./solanaClusterStats"; type Props = { children: React.ReactNode }; export function StatsProvider({ children }: Props) { - return {children}; + return ( + + {children} + + ); }