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}
+
+ );
}