Remove Solanabeach dependency from Explorer (#12463)

* remove solana beach socket dependency

* remove socket.io dependency

* timeout / retry button for cluster stats

* update web3 version, add EpochInfo typing, handle no samples case

* derive max TPS from final downsampled arrays

* change block time to slot time
This commit is contained in:
Josh 2020-10-19 20:11:48 -07:00 committed by GitHub
parent 3b3f7341fa
commit c7c6c28455
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 568 additions and 469 deletions

View File

@ -3892,11 +3892,6 @@
}
}
},
"after": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
"integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8="
},
"aggregate-error": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz",
@ -4111,11 +4106,6 @@
"es-abstract": "^1.17.0-next.1"
}
},
"arraybuffer.slice": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz",
"integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog=="
},
"arrify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
@ -4901,11 +4891,6 @@
"resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz",
"integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ=="
},
"backo2": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
"integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
},
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@ -4974,11 +4959,6 @@
"safe-buffer": "^5.0.1"
}
},
"base64-arraybuffer": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz",
"integrity": "sha1-mBjHngWbE1X5fgQooBfIOOkLqBI="
},
"base64-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
@ -5016,11 +4996,6 @@
"file-uri-to-path": "1.0.0"
}
},
"blob": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz",
"integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig=="
},
"block-stream": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz",
@ -5908,21 +5883,11 @@
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs="
},
"component-bind": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
"integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E="
},
"component-emitter": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
},
"component-inherit": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz",
"integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM="
},
"compose-function": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/compose-function/-/compose-function-3.0.3.tgz",
@ -6978,59 +6943,6 @@
"once": "^1.4.0"
}
},
"engine.io-client": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.4.4.tgz",
"integrity": "sha512-iU4CRr38Fecj8HoZEnFtm2EiKGbYZcPn3cHxqNGl/tmdWRf60KhK+9vE0JeSjgnlS/0oynEfLgKbT9ALpim0sQ==",
"requires": {
"component-emitter": "~1.3.0",
"component-inherit": "0.0.3",
"debug": "~3.1.0",
"engine.io-parser": "~2.2.0",
"has-cors": "1.1.0",
"indexof": "0.0.1",
"parseqs": "0.0.6",
"parseuri": "0.0.6",
"ws": "~6.1.0",
"xmlhttprequest-ssl": "~1.5.4",
"yeast": "0.1.2"
},
"dependencies": {
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"ws": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.1.4.tgz",
"integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==",
"requires": {
"async-limiter": "~1.0.0"
}
}
}
},
"engine.io-parser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.2.1.tgz",
"integrity": "sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==",
"requires": {
"after": "0.8.2",
"arraybuffer.slice": "~0.0.7",
"base64-arraybuffer": "0.1.4",
"blob": "0.0.5",
"has-binary2": "~1.0.2"
}
},
"enhanced-resolve": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.3.0.tgz",
@ -8616,26 +8528,6 @@
}
}
},
"has-binary2": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz",
"integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==",
"requires": {
"isarray": "2.0.1"
},
"dependencies": {
"isarray": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
"integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4="
}
}
},
"has-cors": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
"integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk="
},
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
@ -9044,11 +8936,6 @@
"resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz",
"integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc="
},
"indexof": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
"integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10="
},
"infer-owner": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
@ -11880,16 +11767,6 @@
"resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz",
"integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA=="
},
"parseqs": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz",
"integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w=="
},
"parseuri": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz",
"integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow=="
},
"parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -14977,69 +14854,6 @@
"kind-of": "^3.2.0"
}
},
"socket.io-client": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.3.1.tgz",
"integrity": "sha512-YXmXn3pA8abPOY//JtYxou95Ihvzmg8U6kQyolArkIyLd0pgVhrfor/iMsox8cn07WCOOvvuJ6XKegzIucPutQ==",
"requires": {
"backo2": "1.0.2",
"component-bind": "1.0.0",
"component-emitter": "~1.3.0",
"debug": "~3.1.0",
"engine.io-client": "~3.4.0",
"has-binary2": "~1.0.2",
"indexof": "0.0.1",
"parseqs": "0.0.6",
"parseuri": "0.0.6",
"socket.io-parser": "~3.3.0",
"to-array": "0.1.4"
},
"dependencies": {
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}
}
},
"socket.io-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.1.tgz",
"integrity": "sha512-1QLvVAe8dTz+mKmZ07Swxt+LAo4Y1ff50rlyoEx00TQmDFVQYPfcqGvIDJLGaBdhdNCecXtyKpD+EgKGcmmbuQ==",
"requires": {
"component-emitter": "~1.3.0",
"debug": "~3.1.0",
"isarray": "2.0.1"
},
"dependencies": {
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
},
"isarray": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
"integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4="
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}
}
},
"sockjs": {
"version": "0.3.20",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.20.tgz",
@ -15962,11 +15776,6 @@
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz",
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE="
},
"to-array": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz",
"integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA="
},
"to-arraybuffer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
@ -17189,11 +16998,6 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
},
"xmlhttprequest-ssl": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz",
"integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4="
},
"xregexp": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.3.0.tgz",
@ -17342,11 +17146,6 @@
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
}
}
},
"yeast": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
"integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk="
}
}
}

View File

@ -38,7 +38,6 @@
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.3",
"react-select": "^3.1.0",
"socket.io-client": "^2.3.1",
"solana-sdk-wasm": "file:wasm/pkg",
"superstruct": "github:solana-labs/superstruct",
"typescript": "^3.9.7",

View File

@ -4,12 +4,13 @@ import CountUp from "react-countup";
import {
usePerformanceInfo,
PERF_UPDATE_SEC,
PerformanceInfo,
} from "providers/stats/solanaBeach";
ClusterStatsStatus,
} from "providers/stats/solanaClusterStats";
import classNames from "classnames";
import { TableCardBody } from "components/common/TableCardBody";
import { useCluster, Cluster } from "providers/cluster";
import { ChartOptions, ChartTooltipModel } from "chart.js";
import { PerformanceInfo } from "providers/stats/solanaPerformanceInfo";
import { StatsNotReady } from "pages/ClusterStatsPage";
export function TpsCard() {
return (
@ -24,26 +25,12 @@ export function TpsCard() {
function TpsCardBody() {
const performanceInfo = usePerformanceInfo();
const { cluster } = useCluster();
const statsAvailable =
cluster === Cluster.MainnetBeta || cluster === Cluster.Testnet;
if (!statsAvailable) {
if (performanceInfo.status !== ClusterStatsStatus.Ready) {
return (
<div className="card-body text-center">
<div className="text-muted">
Stats are not available for this cluster
</div>
</div>
);
}
if (!performanceInfo) {
return (
<div className="card-body text-center">
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</div>
<StatsNotReady
error={performanceInfo.status === ClusterStatsStatus.Error}
/>
);
}
@ -54,15 +41,15 @@ type Series = "short" | "medium" | "long";
const SERIES: Series[] = ["short", "medium", "long"];
const SERIES_INFO = {
short: {
label: (index: number) => Math.floor(index / 4),
label: (index: number) => index,
interval: "30m",
},
medium: {
label: (index: number) => index,
label: (index: number) => index * 4,
interval: "2h",
},
long: {
label: (index: number) => 3 * index,
label: (index: number) => index * 12,
interval: "6h",
},
};

View File

@ -1,16 +1,18 @@
import React from "react";
import { TableCardBody } from "components/common/TableCardBody";
import { Slot } from "components/common/Slot";
import {
ClusterStatsStatus,
useDashboardInfo,
usePerformanceInfo,
useSetActive,
} from "providers/stats/solanaBeach";
useStatsProvider,
} from "providers/stats/solanaClusterStats";
import { slotsToHumanString } from "utils";
import { useCluster, Cluster } from "providers/cluster";
import { useCluster } from "providers/cluster";
import { TpsCard } from "components/TpsCard";
const CLUSTER_STATS_TIMEOUT = 10000;
export function ClusterStatsPage() {
return (
<div className="container mt-4">
@ -32,44 +34,33 @@ export function ClusterStatsPage() {
function StatsCardBody() {
const dashboardInfo = useDashboardInfo();
const performanceInfo = usePerformanceInfo();
const setSocketActive = useSetActive();
const { setActive } = useStatsProvider();
const { cluster } = useCluster();
React.useEffect(() => {
setSocketActive(true);
return () => setSocketActive(false);
}, [setSocketActive, cluster]);
setActive(true);
return () => setActive(false);
}, [setActive, cluster]);
const statsAvailable =
cluster === Cluster.MainnetBeta || cluster === Cluster.Testnet;
if (!statsAvailable) {
return (
<div className="card-body text-center">
<div className="text-muted">
Stats are not available for this cluster
</div>
</div>
);
if (
performanceInfo.status !== ClusterStatsStatus.Ready ||
dashboardInfo.status !== ClusterStatsStatus.Ready
) {
const error =
performanceInfo.status === ClusterStatsStatus.Error ||
dashboardInfo.status === ClusterStatsStatus.Error;
return <StatsNotReady error={error} />;
}
if (!dashboardInfo || !performanceInfo) {
return (
<div className="card-body text-center">
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</div>
);
}
const { avgBlockTime_1h, avgBlockTime_1min, epochInfo } = dashboardInfo;
const hourlyBlockTime = Math.round(1000 * avgBlockTime_1h);
const averageBlockTime = Math.round(1000 * avgBlockTime_1min) + "ms";
const { avgSlotTime_1h, avgSlotTime_1min, epochInfo } = dashboardInfo;
const hourlySlotTime = Math.round(1000 * avgSlotTime_1h);
const averageSlotTime = Math.round(1000 * avgSlotTime_1min) + "ms";
const { slotIndex, slotsInEpoch } = epochInfo;
const currentEpoch = epochInfo.epoch.toString();
const epochProgress = ((100 * slotIndex) / slotsInEpoch).toFixed(1) + "%";
const epochTimeRemaining = slotsToHumanString(
slotsInEpoch - slotIndex,
hourlyBlockTime
hourlySlotTime
);
const { blockHeight, absoluteSlot } = epochInfo;
@ -81,15 +72,17 @@ function StatsCardBody() {
<Slot slot={absoluteSlot} />
</td>
</tr>
{blockHeight !== undefined && (
<tr>
<td className="w-100">Block height</td>
<td className="text-lg-right text-monospace">
<Slot slot={blockHeight} />
</td>
</tr>
)}
<tr>
<td className="w-100">Block height</td>
<td className="text-lg-right text-monospace">
<Slot slot={blockHeight} />
</td>
</tr>
<tr>
<td className="w-100">Block time</td>
<td className="text-lg-right text-monospace">{averageBlockTime}</td>
<td className="w-100">Slot time</td>
<td className="text-lg-right text-monospace">{averageSlotTime}</td>
</tr>
<tr>
<td className="w-100">Epoch</td>
@ -106,3 +99,44 @@ function StatsCardBody() {
</TableCardBody>
);
}
export function StatsNotReady({ error }: { error: boolean }) {
const { setTimedOut, retry, active } = useStatsProvider();
const { cluster } = useCluster();
React.useEffect(() => {
let timedOut = 0;
if (!error) {
timedOut = setTimeout(setTimedOut, CLUSTER_STATS_TIMEOUT);
}
return () => {
if (timedOut) {
clearTimeout(timedOut);
}
};
}, [setTimedOut, cluster, error]);
if (error || !active) {
return (
<div className="card-body text-center">
There was a problem loading cluster stats.{" "}
<button
className="btn btn-white btn-sm"
onClick={() => {
retry();
}}
>
<span className="fe fe-refresh-cw mr-2"></span>
Try Again
</button>
</div>
);
}
return (
<div className="card-body text-center">
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</div>
);
}

View File

@ -1,7 +1,7 @@
import React from "react";
import { SolanaBeachProvider } from "./solanaBeach";
import { SolanaClusterStatsProvider } from "./solanaClusterStats";
type Props = { children: React.ReactNode };
export function StatsProvider({ children }: Props) {
return <SolanaBeachProvider>{children}</SolanaBeachProvider>;
return <SolanaClusterStatsProvider>{children}</SolanaClusterStatsProvider>;
}

View File

@ -1,202 +0,0 @@
import React from "react";
import io from "socket.io-client";
import { pick, array, nullable, number, is, StructType } from "superstruct";
import { useCluster, Cluster } from "providers/cluster";
const DashboardInfo = pick({
avgBlockTime_1h: number(),
avgBlockTime_1min: number(),
epochInfo: pick({
absoluteSlot: number(),
blockHeight: number(),
epoch: number(),
slotIndex: number(),
slotsInEpoch: number(),
}),
});
const RootInfo = pick({
root: number(),
});
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(),
});
type SetActive = React.Dispatch<React.SetStateAction<boolean>>;
const SetActiveContext = React.createContext<
{ setActive: SetActive } | undefined
>(undefined);
type RootInfo = StructType<typeof RootInfo>;
type RootState = { slot: number | undefined };
const RootContext = React.createContext<RootState | undefined>(undefined);
type DashboardInfo = StructType<typeof DashboardInfo>;
type DashboardState = { info: DashboardInfo | undefined };
const DashboardContext = React.createContext<DashboardState | undefined>(
undefined
);
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<PerformanceState | undefined>(
undefined
);
const MAINNET_URL = "https://api.solanabeach.io:8443/mainnet";
const TESTNET_URL = "https://api.solanabeach.io:8443/tds";
type Props = { children: React.ReactNode };
export function SolanaBeachProvider({ children }: Props) {
const { cluster } = useCluster();
const [active, setActive] = React.useState(false);
const [root, setRoot] = React.useState<number>();
const [dashboardInfo, setDashboardInfo] = React.useState<DashboardInfo>();
const [performanceInfo, setPerformanceInfo] = React.useState<
PerformanceInfo
>();
React.useEffect(() => {
if (!active) return;
let socket: SocketIOClient.Socket;
if (cluster === Cluster.MainnetBeta) {
socket = io(MAINNET_URL);
} else if (cluster === Cluster.Testnet) {
socket = io(TESTNET_URL);
} else {
return;
}
socket.on("connect", () => {
socket.emit("request_dashboardInfo");
socket.emit("request_performanceInfo");
});
socket.on("error", (err: any) => {
console.error(err);
});
socket.on("dashboardInfo", (data: any) => {
if (is(data, DashboardInfo)) {
setDashboardInfo(data);
}
});
socket.on("performanceInfo", (data: any) => {
if (is(data, PerformanceInfo)) {
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<number | null>, 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) => {
if (is(data, RootInfo)) {
setRoot(data.root);
}
});
return () => {
socket.disconnect();
};
}, [active, cluster]);
// Reset info whenever the cluster changes
React.useEffect(() => {
return () => {
setDashboardInfo(undefined);
setPerformanceInfo(undefined);
setRoot(undefined);
};
}, [cluster]);
return (
<SetActiveContext.Provider value={{ setActive }}>
<DashboardContext.Provider value={{ info: dashboardInfo }}>
<PerformanceContext.Provider value={{ info: performanceInfo }}>
<RootContext.Provider value={{ slot: root }}>
{children}
</RootContext.Provider>
</PerformanceContext.Provider>
</DashboardContext.Provider>
</SetActiveContext.Provider>
);
}
export function useSetActive() {
const context = React.useContext(SetActiveContext);
if (!context) {
throw new Error(`useSetActive must be used within a StatsProvider`);
}
return context.setActive;
}
export function useDashboardInfo() {
const context = React.useContext(DashboardContext);
if (!context) {
throw new Error(`useDashboardInfo must be used within a StatsProvider`);
}
return context.info;
}
export function usePerformanceInfo() {
const context = React.useContext(PerformanceContext);
if (!context) {
throw new Error(`usePerformanceInfo must be used within a StatsProvider`);
}
return context.info;
}
export function useRootSlot() {
const context = React.useContext(RootContext);
if (!context) {
throw new Error(`useRootSlot must be used within a StatsProvider`);
}
return context.slot;
}

View File

@ -0,0 +1,261 @@
import React from "react";
import { Connection } from "@solana/web3.js";
import { useCluster, Cluster } from "providers/cluster";
import {
DashboardInfo,
DashboardInfoActionType,
dashboardInfoReducer,
} from "./solanaDashboardInfo";
import {
PerformanceInfo,
PerformanceInfoActionType,
performanceInfoReducer,
} from "./solanaPerformanceInfo";
import { reportError } from "utils/sentry";
export const PERF_UPDATE_SEC = 5;
export const SAMPLE_HISTORY_HOURS = 6;
export const PERFORMANCE_SAMPLE_INTERVAL = 60000;
export const TRANSACTION_COUNT_INTERVAL = 5000;
export const EPOCH_INFO_INTERVAL = 2000;
export const LOADING_TIMEOUT = 10000;
export enum ClusterStatsStatus {
Loading,
Ready,
Error,
}
const initialPerformanceInfo: PerformanceInfo = {
status: ClusterStatsStatus.Loading,
avgTps: 0,
historyMaxTps: 0,
perfHistory: {
short: [],
medium: [],
long: [],
},
transactionCount: 0,
};
const initialDashboardInfo: DashboardInfo = {
status: ClusterStatsStatus.Loading,
avgSlotTime_1h: 0,
avgSlotTime_1min: 0,
epochInfo: {
absoluteSlot: 0,
blockHeight: 0,
epoch: 0,
slotIndex: 0,
slotsInEpoch: 0,
},
};
type SetActive = React.Dispatch<React.SetStateAction<boolean>>;
const StatsProviderContext = React.createContext<
| {
setActive: SetActive;
setTimedOut: Function;
retry: Function;
active: boolean;
}
| undefined
>(undefined);
type DashboardState = { info: DashboardInfo };
const DashboardContext = React.createContext<DashboardState | undefined>(
undefined
);
type PerformanceState = { info: PerformanceInfo };
const PerformanceContext = React.createContext<PerformanceState | undefined>(
undefined
);
type Props = { children: React.ReactNode };
export function SolanaClusterStatsProvider({ children }: Props) {
const { cluster, url } = useCluster();
const [active, setActive] = React.useState(false);
const [dashboardInfo, dispatchDashboardInfo] = React.useReducer(
dashboardInfoReducer,
initialDashboardInfo
);
const [performanceInfo, dispatchPerformanceInfo] = React.useReducer(
performanceInfoReducer,
initialPerformanceInfo
);
React.useEffect(() => {
if (!active || !url) return;
const connection = new Connection(url);
const getPerformanceSamples = async () => {
try {
const samples = await connection.getRecentPerformanceSamples(
60 * SAMPLE_HISTORY_HOURS
);
if (samples.length < 1) {
// no samples to work with (node has no history).
return; // we will allow for a timeout instead of throwing an error
}
dispatchPerformanceInfo({
type: PerformanceInfoActionType.SetPerfSamples,
data: samples,
});
dispatchDashboardInfo({
type: DashboardInfoActionType.SetPerfSamples,
data: samples,
});
} catch (error) {
if (cluster !== Cluster.Custom) {
reportError(error, { url });
}
dispatchPerformanceInfo({
type: PerformanceInfoActionType.SetError,
data: error.toString(),
});
dispatchDashboardInfo({
type: DashboardInfoActionType.SetError,
data: error.toString(),
});
setActive(false);
}
};
const getTransactionCount = async () => {
try {
const transactionCount = await connection.getTransactionCount();
dispatchPerformanceInfo({
type: PerformanceInfoActionType.SetTransactionCount,
data: transactionCount,
});
} catch (error) {
if (cluster !== Cluster.Custom) {
reportError(error, { url });
}
dispatchPerformanceInfo({
type: PerformanceInfoActionType.SetError,
data: error.toString(),
});
setActive(false);
}
};
const getEpochInfo = async () => {
try {
const epochInfo = await connection.getEpochInfo();
dispatchDashboardInfo({
type: DashboardInfoActionType.SetEpochInfo,
data: epochInfo,
});
} catch (error) {
if (cluster !== Cluster.Custom) {
reportError(error, { url });
}
dispatchDashboardInfo({
type: DashboardInfoActionType.SetError,
data: error.toString(),
});
setActive(false);
}
};
const performanceInterval = setInterval(
getPerformanceSamples,
PERFORMANCE_SAMPLE_INTERVAL
);
const transactionCountInterval = setInterval(
getTransactionCount,
TRANSACTION_COUNT_INTERVAL
);
const epochInfoInterval = setInterval(getEpochInfo, EPOCH_INFO_INTERVAL);
getPerformanceSamples();
getTransactionCount();
getEpochInfo();
return () => {
clearInterval(performanceInterval);
clearInterval(transactionCountInterval);
clearInterval(epochInfoInterval);
};
}, [active, cluster, url]);
// Reset when cluster changes
React.useEffect(() => {
return () => {
resetData();
};
}, [url]);
function resetData() {
dispatchDashboardInfo({
type: DashboardInfoActionType.Reset,
data: initialDashboardInfo,
});
dispatchPerformanceInfo({
type: PerformanceInfoActionType.Reset,
data: initialPerformanceInfo,
});
}
const setTimedOut = React.useCallback(() => {
dispatchDashboardInfo({
type: DashboardInfoActionType.SetError,
data: "Cluster stats timed out",
});
dispatchPerformanceInfo({
type: PerformanceInfoActionType.SetError,
data: "Cluster stats timed out",
});
if (cluster !== Cluster.Custom) {
reportError(new Error("Cluster stats timed out"), { url });
}
setActive(false);
}, [cluster, url]);
const retry = React.useCallback(() => {
resetData();
setActive(true);
}, []);
return (
<StatsProviderContext.Provider
value={{ setActive, setTimedOut, retry, active }}
>
<DashboardContext.Provider value={{ info: dashboardInfo }}>
<PerformanceContext.Provider value={{ info: performanceInfo }}>
{children}
</PerformanceContext.Provider>
</DashboardContext.Provider>
</StatsProviderContext.Provider>
);
}
export function useStatsProvider() {
const context = React.useContext(StatsProviderContext);
if (!context) {
throw new Error(`useContext must be used within a StatsProvider`);
}
return context;
}
export function useDashboardInfo() {
const context = React.useContext(DashboardContext);
if (!context) {
throw new Error(`useDashboardInfo must be used within a StatsProvider`);
}
return context.info;
}
export function usePerformanceInfo() {
const context = React.useContext(PerformanceContext);
if (!context) {
throw new Error(`usePerformanceInfo must be used within a StatsProvider`);
}
return context.info;
}

View File

@ -0,0 +1,95 @@
import { EpochInfo, PerfSample } from "@solana/web3.js";
import { ClusterStatsStatus } from "./solanaClusterStats";
export type DashboardInfo = {
status: ClusterStatsStatus;
avgSlotTime_1h: number;
avgSlotTime_1min: number;
epochInfo: EpochInfo;
};
export enum DashboardInfoActionType {
SetPerfSamples,
SetEpochInfo,
SetError,
Reset,
}
export type DashboardInfoActionSetPerfSamples = {
type: DashboardInfoActionType.SetPerfSamples;
data: PerfSample[];
};
export type DashboardInfoActionSetEpochInfo = {
type: DashboardInfoActionType.SetEpochInfo;
data: EpochInfo;
};
export type DashboardInfoActionReset = {
type: DashboardInfoActionType.Reset;
data: DashboardInfo;
};
export type DashboardInfoActionSetError = {
type: DashboardInfoActionType.SetError;
data: string;
};
export type DashboardInfoAction =
| DashboardInfoActionSetPerfSamples
| DashboardInfoActionSetEpochInfo
| DashboardInfoActionReset
| DashboardInfoActionSetError;
export function dashboardInfoReducer(
state: DashboardInfo,
action: DashboardInfoAction
) {
const status =
state.avgSlotTime_1h !== 0 && state.epochInfo.absoluteSlot !== 0
? ClusterStatsStatus.Ready
: ClusterStatsStatus.Loading;
switch (action.type) {
case DashboardInfoActionType.SetPerfSamples:
if (action.data.length < 1) {
return state;
}
const samples = action.data
.map((sample) => {
return sample.samplePeriodSecs / sample.numSlots;
})
.slice(0, 60);
const samplesInHour = samples.length < 60 ? samples.length : 60;
const avgSlotTime_1h =
samples.reduce((sum: number, cur: number) => {
return sum + cur;
}, 0) / samplesInHour;
return {
...state,
avgSlotTime_1h,
avgSlotTime_1min: samples[0],
status,
};
case DashboardInfoActionType.SetEpochInfo:
return {
...state,
epochInfo: action.data,
status,
};
case DashboardInfoActionType.SetError:
return {
...state,
status: ClusterStatsStatus.Error,
};
case DashboardInfoActionType.Reset:
return {
...action.data,
};
default:
return state;
}
}

View File

@ -0,0 +1,126 @@
import { PerfSample } from "@solana/web3.js";
import { ClusterStatsStatus } from "./solanaClusterStats";
export type PerformanceInfo = {
status: ClusterStatsStatus;
avgTps: number;
historyMaxTps: number;
perfHistory: {
short: (number | null)[];
medium: (number | null)[];
long: (number | null)[];
};
transactionCount: number;
};
export enum PerformanceInfoActionType {
SetTransactionCount,
SetPerfSamples,
SetError,
Reset,
}
export type PerformanceInfoActionSetTransactionCount = {
type: PerformanceInfoActionType.SetTransactionCount;
data: number;
};
export type PerformanceInfoActionSetPerfSamples = {
type: PerformanceInfoActionType.SetPerfSamples;
data: PerfSample[];
};
export type PerformanceInfoActionSetError = {
type: PerformanceInfoActionType.SetError;
data: string;
};
export type PerformanceInfoActionReset = {
type: PerformanceInfoActionType.Reset;
data: PerformanceInfo;
};
export type PerformanceInfoAction =
| PerformanceInfoActionSetTransactionCount
| PerformanceInfoActionSetPerfSamples
| PerformanceInfoActionSetError
| PerformanceInfoActionReset;
export function performanceInfoReducer(
state: PerformanceInfo,
action: PerformanceInfoAction
) {
const status =
state.avgTps !== 0 && state.transactionCount !== 0
? ClusterStatsStatus.Ready
: ClusterStatsStatus.Loading;
switch (action.type) {
case PerformanceInfoActionType.SetPerfSamples:
if (action.data.length < 1) {
return state;
}
let short = action.data.map((sample) => {
return sample.numTransactions / sample.samplePeriodSecs;
});
const avgTps = short[0];
const medium = downsampleByFactor(short, 4);
const long = downsampleByFactor(medium, 3);
const perfHistory = {
short: round(short.slice(0, 30)).reverse(),
medium: round(medium.slice(0, 30)).reverse(),
long: round(long.slice(0, 30)).reverse(),
};
const historyMaxTps = Math.max(
Math.max(...perfHistory.short),
Math.max(...perfHistory.medium),
Math.max(...perfHistory.long)
);
return {
...state,
historyMaxTps,
avgTps,
perfHistory,
status,
};
case PerformanceInfoActionType.SetTransactionCount:
return {
...state,
transactionCount: action.data,
status,
};
case PerformanceInfoActionType.SetError:
return {
...state,
status: ClusterStatsStatus.Error,
};
case PerformanceInfoActionType.Reset:
return {
...action.data,
};
default:
return state;
}
}
function downsampleByFactor(series: number[], factor: number) {
return series.reduce((result: number[], num: number, i: number) => {
const downsampledIndex = Math.floor(i / factor);
if (result.length < downsampledIndex + 1) {
result.push(0);
}
const mean = result[downsampledIndex];
const differential = (num - mean) / ((i % factor) + 1);
result[downsampledIndex] = mean + differential;
return result;
}, []);
}
function round(series: number[]) {
return series.map((n) => Math.round(n));
}