diff --git a/app/api/ping/[network]/route.ts b/app/api/ping/[network]/route.ts new file mode 100644 index 00000000..97d2ecfe --- /dev/null +++ b/app/api/ping/[network]/route.ts @@ -0,0 +1,49 @@ +import { fetch } from 'cross-fetch'; +import { NextResponse } from 'next/server'; + +type Params = { + params: { + network: 'mainnet'; + }; +}; + +export type ValidatorsAppPingStats = { + interval: number; + max: number; + median: number; + min: number; + network: string; + num_of_records: number; + time_from: string; + average_slot_latency: number; + tps: number; +}; + +const PING_INTERVALS: number[] = [1, 3, 12]; + +export async function GET(_request: Request, { params: { network } }: Params) { + const responses = await Promise.all( + PING_INTERVALS.map(interval => + fetch(`https://www.validators.app/api/v1/ping-thing-stats/${network}.json?interval=${interval}`, { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-store, max-age=0', + Token: process.env.PING_API_KEY || '', + }, + }) + ) + ); + const data: { [interval: number]: ValidatorsAppPingStats[] } = {}; + await Promise.all( + responses.map(async (response, index) => { + const interval = PING_INTERVALS[index]; + data[interval] = await response.json(); + }) + ); + + return NextResponse.json(data, { + headers: { + 'Cache-Control': 'public, max-age=30', + }, + }); +} diff --git a/app/components/LiveTransactionStatsCard.tsx b/app/components/LiveTransactionStatsCard.tsx index ec46e784..5d92c950 100644 --- a/app/components/LiveTransactionStatsCard.tsx +++ b/app/components/LiveTransactionStatsCard.tsx @@ -12,6 +12,14 @@ import { Bar } from 'react-chartjs-2'; import CountUp from 'react-countup'; import { RefreshCw } from 'react-feather'; +import { useCluster } from '../providers/cluster'; +import { + useValidatorsAppPingStats, + ValidatorsAppPingStatsInfo, + ValidatorsAppPingStatsRecord, +} from '../providers/stats/ValidatorsAppStatsProvider'; +import { Cluster } from '../utils/cluster'; + Chart.register(BarElement, CategoryScale, LinearScale, Tooltip); type Series = 'short' | 'medium' | 'long'; @@ -34,24 +42,18 @@ const SERIES_INFO = { export function LiveTransactionStatsCard() { const [series, setSeries] = React.useState('short'); + const { cluster } = useCluster(); return (

Live Transaction Stats

-
- Note: We are aware of an issue with ping statistic reporting. Ping statistics may not reflect actual - network performance. Please see{' '} - - validators.app - {' '} - for more information. -
- + {cluster === Cluster.MainnetBeta ? ( + + ) : ( + + )}
); } @@ -66,6 +68,29 @@ function TpsCardBody({ series, setSeries }: { series: Series; setSeries: SetSeri return ; } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function ValidatorsAppTpsCardBody({ series, setSeries }: { series: Series; setSeries: SetSeries }) { + const performanceInfo = usePerformanceInfo(); + const statsInfo = useValidatorsAppPingStats(); + + if (performanceInfo.status !== ClusterStatsStatus.Ready || statsInfo.status !== PingStatus.Ready) { + return ( + + ); + } + + return ( + + ); +} + const TPS_CHART_OPTIONS = (historyMaxTps: number): ChartOptions<'bar'> => { return { animation: false, @@ -157,6 +182,84 @@ const TPS_CHART_OPTIONS = (historyMaxTps: number): ChartOptions<'bar'> => { }; }; +type ValidatorsAppTpsBarChartProps = { + performanceInfo: PerformanceInfo; + statsInfo: ValidatorsAppPingStatsInfo; + series: Series; + setSeries: SetSeries; +}; +function ValidatorsAppTpsBarChart({ statsInfo, performanceInfo, series, setSeries }: ValidatorsAppTpsBarChartProps) { + const seriesData = statsInfo[series] || []; + const avgTps = Math.round( + (statsInfo[SERIES[2]] || [])?.map(val => val.tps).reduce((a, b) => a + b, 0) / seriesData.length + ); + const historyMaxTps = (statsInfo[SERIES[2]] || [])?.map(val => val.tps).reduce((a, b) => Math.max(a, b), 0); + const averageTps = Math.round(avgTps).toLocaleString('en-US'); + const transactionCount = ; + const chartOptions = React.useMemo>(() => TPS_CHART_OPTIONS(historyMaxTps), [historyMaxTps]); + + const now = new Date(); + const chartData: ChartData<'bar'> = { + datasets: [ + { + backgroundColor: '#00D192', + borderWidth: 0, + data: seriesData.map(val => (val.tps >= historyMaxTps ? avgTps : val.tps || 0)), + hoverBackgroundColor: '#00D192', + }, + ], + labels: seriesData.map(val => { + const minAgo = Math.round((now.getTime() - val.timestamp.getTime()) / 60000); + return `${minAgo}min ago`; + }), + }; + + return ( + <> + + + Transaction count + {transactionCount} + + + Transactions per second (TPS) + {averageTps} + + + +
+ +
+
+
+ TPS history + +
+ {SERIES.map(key => ( + + ))} +
+
+ +
+
+ +
+
+
+
+ + ); +} + type TpsBarChartProps = { performanceInfo: PerformanceInfo; series: Series; @@ -287,6 +390,16 @@ function PingStatsCardBody({ series, setSeries }: { series: Series; setSeries: S return ; } +function ValidatorsAppPingStatsCardBody({ series, setSeries }: { series: Series; setSeries: SetSeries }) { + const pingInfo = useValidatorsAppPingStats(); + + if (pingInfo.status !== PingStatus.Ready) { + return ; + } + + return ; +} + type StatsNotReadyProps = { error: boolean; retry?: () => void }; function PingStatsNotReady({ error, retry }: StatsNotReadyProps) { if (error) { @@ -498,3 +611,92 @@ function PingBarChart({ ); } + +function ValidatorsAppPingBarChart({ + pingInfo, + series, + setSeries, +}: { + pingInfo: ValidatorsAppPingStatsInfo; + series: Series; + setSeries: SetSeries; +}) { + const seriesData = pingInfo[series] || []; + const maxMedian = seriesData.reduce((a, b) => { + return Math.max(a, b.median); + }, 0); + const backgroundColor = (val: ValidatorsAppPingStatsRecord) => { + if (val.submitted === 0) { + return '#08a274'; + } + + return '#00D192'; + }; + const now = new Date(); + const chartData: ChartData<'bar'> = { + datasets: [ + { + backgroundColor: seriesData.map(backgroundColor), + borderWidth: 0, + data: seriesData.map(val => { + if (val.submitted === 0) { + return maxMedian * 0.5; + } + return val.median || 0; + }), + hoverBackgroundColor: seriesData.map(backgroundColor), + minBarLength: 2, + }, + ], + labels: seriesData.map(val => { + const minutesAgo = Math.round((now.getTime() - val.timestamp.getTime()) / (60 * 1000)); + if (val.submitted === 0) { + return ` +
+

Ping statistics unavailable

+ ${minutesAgo} min ago +
+ `; + } + + return ` +
${val.median} ms
+
+

${val.submitted} confirmed

+ ${`

${val.average_slot_latency.toLocaleString(undefined)} Average Slot Latency

`} + ${minutesAgo}min ago +
+ `; + }), + }; + + return ( +
+
+
+ Average Ping Time + +
+ {SERIES.map(key => ( + + ))} +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/app/providers/stats/ValidatorsAppStatsProvider.tsx b/app/providers/stats/ValidatorsAppStatsProvider.tsx new file mode 100644 index 00000000..816476a5 --- /dev/null +++ b/app/providers/stats/ValidatorsAppStatsProvider.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useCluster } from '@providers/cluster'; +import { useStatsProvider } from '@providers/stats/solanaClusterStats'; +import { Cluster } from '@utils/cluster'; +import { fetch } from 'cross-fetch'; +import React from 'react'; +import useTabVisibility from 'use-tab-visibility'; + +import { ValidatorsAppPingStats } from '@/app/api/ping/[network]/route'; + +const PING_INTERVALS: number[] = [1, 3, 12]; + +const FETCH_PING_INTERVAL = 60 * 1000; + +function getClusterSlug(cluster: Cluster) { + switch (cluster) { + case Cluster.MainnetBeta: + return 'mainnet'; + case Cluster.Devnet: + return 'devnet'; + default: + throw new Error(`Invalid cluster: ${cluster}`); + } +} + +function getUrl(cluster: Cluster) { + return `/api/ping/${getClusterSlug(cluster)}`; +} + +export enum PingStatus { + Loading, + Ready, + Error, +} + +export type ValidatorsAppPingStatsInfo = { + status: PingStatus; + short?: ValidatorsAppPingStatsRecord[]; + medium?: ValidatorsAppPingStatsRecord[]; + long?: ValidatorsAppPingStatsRecord[]; + retry?: () => void; +}; + +export type ValidatorsAppPingStatsRecord = { + tps: number; + median: number; + submitted: number; + average_slot_latency: number; + timestamp: Date; +}; + +const ValidatorsAppStatsContext = React.createContext(undefined); + +type Props = { children: React.ReactNode }; + +export function ValidatorsAppStatsProvider({ children }: Props) { + const { cluster } = useCluster(); + const { active } = useStatsProvider(); + const [rollup, setRollup] = React.useState({ + status: PingStatus.Loading, + }); + const { visible: isTabVisible } = useTabVisibility(); + React.useEffect(() => { + if (!active || !isTabVisible) { + return; + } + + if (cluster === Cluster.Testnet || cluster === Cluster.Custom) { + return; + } + + const url = getUrl(cluster); + + setRollup({ + status: PingStatus.Loading, + }); + + if (!url) { + return; + } + let stale = false; + const fetchPingMetrics = async () => { + try { + const response = await fetch(url); + if (stale) { + return; + } + const json: { [interval: number]: ValidatorsAppPingStats[] } = await response.json(); + if (stale) { + return; + } + const simplify = (data: ValidatorsAppPingStats[]) => + data.map( + ({ tps, median, time_from, num_of_records, average_slot_latency }: ValidatorsAppPingStats) => { + return { + average_slot_latency, + median, + submitted: num_of_records, + timestamp: new Date(time_from), + tps, + }; + } + ); + + const short = simplify(json[PING_INTERVALS[0]]).slice(-30); + const medium = simplify(json[PING_INTERVALS[1]]).slice(-30); + const long = simplify(json[PING_INTERVALS[2]]).slice(-30); + + setRollup({ + long, + medium, + short, + status: PingStatus.Ready, + }); + } catch { + setRollup({ + retry: () => { + setRollup({ + status: PingStatus.Loading, + }); + + fetchPingMetrics(); + }, + status: PingStatus.Error, + }); + } + }; + + const fetchPingInterval = setInterval(() => { + fetchPingMetrics(); + }, FETCH_PING_INTERVAL); + fetchPingMetrics(); + return () => { + clearInterval(fetchPingInterval); + stale = true; + }; + }, [active, cluster, isTabVisible]); + + return {children}; +} + +export function useValidatorsAppPingStats() { + const context = React.useContext(ValidatorsAppStatsContext); + if (!context) { + throw new Error(`useContext must be used within a StatsProvider`); + } + return context; +} diff --git a/app/providers/stats/index.tsx b/app/providers/stats/index.tsx index 6b44420c..f3e76a3f 100644 --- a/app/providers/stats/index.tsx +++ b/app/providers/stats/index.tsx @@ -2,12 +2,15 @@ import { SolanaPingProvider } from '@providers/stats/SolanaPingProvider'; import React from 'react'; import { SolanaClusterStatsProvider } from './solanaClusterStats'; +import { ValidatorsAppStatsProvider } from './ValidatorsAppStatsProvider'; type Props = { children: React.ReactNode }; export function StatsProvider({ children }: Props) { return ( - {children} + + {children} + ); }