diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 1557d1a8a8..88065f1975 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -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=" } } } diff --git a/explorer/package.json b/explorer/package.json index b50a51b033..3ddbc4dbc0 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -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", diff --git a/explorer/src/components/TpsCard.tsx b/explorer/src/components/TpsCard.tsx index 9fdbbedd18..81a6cef318 100644 --- a/explorer/src/components/TpsCard.tsx +++ b/explorer/src/components/TpsCard.tsx @@ -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 ( -
-
- Stats are not available for this cluster -
-
- ); - } - - if (!performanceInfo) { - return ( -
- - Loading -
+ ); } @@ -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", }, }; diff --git a/explorer/src/pages/ClusterStatsPage.tsx b/explorer/src/pages/ClusterStatsPage.tsx index 7cb8c701a0..21c7937d7d 100644 --- a/explorer/src/pages/ClusterStatsPage.tsx +++ b/explorer/src/pages/ClusterStatsPage.tsx @@ -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 (
@@ -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 ( -
-
- Stats are not available for this cluster -
-
- ); + if ( + performanceInfo.status !== ClusterStatsStatus.Ready || + dashboardInfo.status !== ClusterStatsStatus.Ready + ) { + const error = + performanceInfo.status === ClusterStatsStatus.Error || + dashboardInfo.status === ClusterStatsStatus.Error; + return ; } - if (!dashboardInfo || !performanceInfo) { - return ( -
- - Loading -
- ); - } - - 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() { + {blockHeight !== undefined && ( + + Block height + + + + + )} - Block height - - - - - - Block time - {averageBlockTime} + Slot time + {averageSlotTime} Epoch @@ -106,3 +99,44 @@ function StatsCardBody() { ); } + +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 ( +
+ There was a problem loading cluster stats.{" "} + +
+ ); + } + + return ( +
+ + Loading +
+ ); +} diff --git a/explorer/src/providers/stats/index.tsx b/explorer/src/providers/stats/index.tsx index 60a482d5fc..56e20c9980 100644 --- a/explorer/src/providers/stats/index.tsx +++ b/explorer/src/providers/stats/index.tsx @@ -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 {children}; + return {children}; } diff --git a/explorer/src/providers/stats/solanaBeach.tsx b/explorer/src/providers/stats/solanaBeach.tsx deleted file mode 100644 index d3893290a2..0000000000 --- a/explorer/src/providers/stats/solanaBeach.tsx +++ /dev/null @@ -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>; -const SetActiveContext = React.createContext< - { setActive: SetActive } | undefined ->(undefined); - -type RootInfo = StructType; -type RootState = { slot: number | undefined }; -const RootContext = React.createContext(undefined); - -type DashboardInfo = StructType; -type DashboardState = { info: DashboardInfo | undefined }; -const DashboardContext = React.createContext( - 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( - 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(); - const [dashboardInfo, setDashboardInfo] = React.useState(); - 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, 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 ( - - - - - {children} - - - - - ); -} - -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; -} diff --git a/explorer/src/providers/stats/solanaClusterStats.tsx b/explorer/src/providers/stats/solanaClusterStats.tsx new file mode 100644 index 0000000000..52bd9e7844 --- /dev/null +++ b/explorer/src/providers/stats/solanaClusterStats.tsx @@ -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>; +const StatsProviderContext = React.createContext< + | { + setActive: SetActive; + setTimedOut: Function; + retry: Function; + active: boolean; + } + | undefined +>(undefined); + +type DashboardState = { info: DashboardInfo }; +const DashboardContext = React.createContext( + undefined +); + +type PerformanceState = { info: PerformanceInfo }; +const PerformanceContext = React.createContext( + 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 ( + + + + {children} + + + + ); +} + +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; +} diff --git a/explorer/src/providers/stats/solanaDashboardInfo.tsx b/explorer/src/providers/stats/solanaDashboardInfo.tsx new file mode 100644 index 0000000000..9509979909 --- /dev/null +++ b/explorer/src/providers/stats/solanaDashboardInfo.tsx @@ -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; + } +} diff --git a/explorer/src/providers/stats/solanaPerformanceInfo.tsx b/explorer/src/providers/stats/solanaPerformanceInfo.tsx new file mode 100644 index 0000000000..a2d73111da --- /dev/null +++ b/explorer/src/providers/stats/solanaPerformanceInfo.tsx @@ -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)); +}