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:
Josh 2022-03-01 10:50:48 -08:00 committed by GitHub
parent 93c5642f9f
commit f6a06826d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 398 additions and 1 deletions

View File

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

View File

@ -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() {
<StatsCardBody />
</div>
<TpsCard />
<SolanaPingCard />
</div>
);
}

View File

@ -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;
}

View File

@ -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 <SolanaClusterStatsProvider>{children}</SolanaClusterStatsProvider>;
return (
<SolanaClusterStatsProvider>
<SolanaPingProvider>{children}</SolanaPingProvider>
</SolanaClusterStatsProvider>
);
}