feat(explorer): add Solana Ping to cluster stats page (#23239)
* feat: add Solana Ping to explorer * fix: remove br tags in label
This commit is contained in:
parent
93c5642f9f
commit
f6a06826d8
|
@ -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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h4 className="card-header-title">Solana Ping Stats</h4>
|
||||||
|
</div>
|
||||||
|
<PingBarBody />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PingBarBody() {
|
||||||
|
const pingInfo = useSolanaPingInfo();
|
||||||
|
|
||||||
|
if (pingInfo.status !== PingStatus.Ready) {
|
||||||
|
return (
|
||||||
|
<StatsNotReady
|
||||||
|
error={pingInfo.status === PingStatus.Error}
|
||||||
|
retry={pingInfo.retry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PingBarChart pingInfo={pingInfo} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsNotReadyProps = { error: boolean; retry?: Function };
|
||||||
|
function StatsNotReady({ error, retry }: StatsNotReadyProps) {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card-body text-center">
|
||||||
|
There was a problem loading solana ping stats.{" "}
|
||||||
|
{retry && (
|
||||||
|
<button
|
||||||
|
className="btn btn-white btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
retry();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="fe fe-refresh-cw me-2"></span>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<span className="spinner-grow spinner-grow-sm me-2"></span>
|
||||||
|
Loading
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<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} ms</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_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<Series>("short");
|
||||||
|
const seriesData = pingInfo[series] || [];
|
||||||
|
|
||||||
|
const seriesLength = seriesData.length;
|
||||||
|
const chartData: Chart.ChartData = {
|
||||||
|
labels: seriesData.map((val, i) => {
|
||||||
|
return `
|
||||||
|
<p class="mb-0">${val.confirmed} of ${val.submitted} confirmed</p>
|
||||||
|
${
|
||||||
|
val.loss
|
||||||
|
? `<p class="mb-0">${val.loss.toLocaleString(undefined, {
|
||||||
|
style: "percent",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})} loss</p>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${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 (
|
||||||
|
<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">Average Confirmation Time</span>
|
||||||
|
|
||||||
|
<div className="font-size-sm">
|
||||||
|
{SERIES.map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setSeries(key)}
|
||||||
|
className={classNames("btn btn-sm btn-white ms-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={CHART_OPTION} height={80} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ import { useVoteAccounts } from "providers/accounts/vote-accounts";
|
||||||
import { CoingeckoStatus, useCoinGecko } from "utils/coingecko";
|
import { CoingeckoStatus, useCoinGecko } from "utils/coingecko";
|
||||||
import { Epoch } from "components/common/Epoch";
|
import { Epoch } from "components/common/Epoch";
|
||||||
import { TimestampToggle } from "components/common/TimestampToggle";
|
import { TimestampToggle } from "components/common/TimestampToggle";
|
||||||
|
import { SolanaPingCard } from "components/SolanaPingCard";
|
||||||
|
|
||||||
const CLUSTER_STATS_TIMEOUT = 5000;
|
const CLUSTER_STATS_TIMEOUT = 5000;
|
||||||
|
|
||||||
|
@ -36,6 +37,7 @@ export function ClusterStatsPage() {
|
||||||
<StatsCardBody />
|
<StatsCardBody />
|
||||||
</div>
|
</div>
|
||||||
<TpsCard />
|
<TpsCard />
|
||||||
|
<SolanaPingCard />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<PingRollupInfo | undefined>(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<PingRollupInfo | undefined>({
|
||||||
|
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<PingInfo>(
|
||||||
|
({ 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 <PingContext.Provider value={rollup}>{children}</PingContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSolanaPingInfo() {
|
||||||
|
const context = React.useContext(PingContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(`useContext must be used within a StatsProvider`);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
|
@ -1,7 +1,12 @@
|
||||||
|
import { SolanaPingProvider } from "providers/stats/SolanaPingProvider";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { SolanaClusterStatsProvider } from "./solanaClusterStats";
|
import { SolanaClusterStatsProvider } from "./solanaClusterStats";
|
||||||
|
|
||||||
type Props = { children: React.ReactNode };
|
type Props = { children: React.ReactNode };
|
||||||
export function StatsProvider({ children }: Props) {
|
export function StatsProvider({ children }: Props) {
|
||||||
return <SolanaClusterStatsProvider>{children}</SolanaClusterStatsProvider>;
|
return (
|
||||||
|
<SolanaClusterStatsProvider>
|
||||||
|
<SolanaPingProvider>{children}</SolanaPingProvider>
|
||||||
|
</SolanaClusterStatsProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue