diff --git a/ui/src/app/api/stats/metering/route.ts b/ui/src/app/api/stats/metering/route.ts new file mode 100644 index 0000000..38f2b6b --- /dev/null +++ b/ui/src/app/api/stats/metering/route.ts @@ -0,0 +1,246 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { type BlockData, getBlockFromCache } from "@/lib/s3"; + +const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545"; + +export interface MeteringStatsResponse { + timeWindowStart: number; + timeWindowEnd: number; + blockCount: number; + transactionCount: number; + stats: { + avgExecutionTimeUs: number; + minExecutionTime: { + timeUs: number; + txHash: string; + blockNumber: number; + } | null; + maxExecutionTime: { + timeUs: number; + txHash: string; + blockNumber: number; + } | null; + p50ExecutionTimeUs: number; + p95ExecutionTimeUs: number; + p99ExecutionTimeUs: number; + avgGasEfficiency: number; + }; +} + +async function fetchLatestBlockNumber(): Promise { + try { + const response = await fetch(RPC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_blockNumber", + params: [], + id: 1, + }), + }); + + const data = await response.json(); + if (data.error || !data.result) { + return null; + } + + return parseInt(data.result, 16); + } catch (error) { + console.error("Failed to fetch latest block number:", error); + return null; + } +} + +async function fetchBlockHashByNumber( + blockNumber: number, +): Promise { + try { + const response = await fetch(RPC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: [`0x${blockNumber.toString(16)}`, false], + id: 1, + }), + }); + + const data = await response.json(); + if (data.error || !data.result) { + return null; + } + + return data.result.hash; + } catch (error) { + console.error(`Failed to fetch block hash for ${blockNumber}:`, error); + return null; + } +} + +function calculatePercentile( + sortedValues: number[], + percentile: number, +): number { + if (sortedValues.length === 0) return 0; + const index = Math.floor(sortedValues.length * percentile); + return sortedValues[Math.min(index, sortedValues.length - 1)]; +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const blockCount = parseInt(searchParams.get("blocks") || "150", 10); + + const latestBlockNumber = await fetchLatestBlockNumber(); + if (latestBlockNumber === null) { + return NextResponse.json( + { error: "Failed to fetch latest block number" }, + { status: 500 }, + ); + } + + const blockNumbers = Array.from( + { length: blockCount }, + (_, i) => latestBlockNumber - i, + ).filter((n) => n >= 0); + + const blockHashes = await Promise.all( + blockNumbers.map(async (num) => ({ + number: num, + hash: await fetchBlockHashByNumber(num), + })), + ); + + const blocks = await Promise.all( + blockHashes + .filter((b): b is { number: number; hash: string } => b.hash !== null) + .map(async (b) => ({ + number: b.number, + data: await getBlockFromCache(b.hash), + })), + ); + + const validBlocks = blocks.filter((b) => b.data !== null) as Array<{ + number: number; + data: BlockData; + }>; + + interface TransactionWithMetering { + executionTimeUs: number; + gasUsed: bigint; + txHash: string; + blockNumber: number; + } + + const txsWithMetering: TransactionWithMetering[] = validBlocks.flatMap( + (block) => + block.data.transactions + .filter( + (tx): tx is typeof tx & { executionTimeUs: number } => + tx.executionTimeUs !== null, + ) + .map((tx) => ({ + executionTimeUs: tx.executionTimeUs, + gasUsed: tx.gasUsed, + txHash: tx.hash, + blockNumber: block.number, + })), + ); + + if (txsWithMetering.length === 0) { + return NextResponse.json( + { + timeWindowStart: 0, + timeWindowEnd: 0, + blockCount: validBlocks.length, + transactionCount: 0, + stats: { + avgExecutionTimeUs: 0, + minExecutionTime: null, + maxExecutionTime: null, + p50ExecutionTimeUs: 0, + p95ExecutionTimeUs: 0, + p99ExecutionTimeUs: 0, + avgGasEfficiency: 0, + }, + } as MeteringStatsResponse, + { + headers: { + "Cache-Control": "public, s-maxage=30, stale-while-revalidate=60", + }, + }, + ); + } + + const timestamps = validBlocks + .map((b) => Number(b.data.timestamp)) + .filter((t) => t > 0); + const timeWindowStart = timestamps.length > 0 ? Math.min(...timestamps) : 0; + const timeWindowEnd = timestamps.length > 0 ? Math.max(...timestamps) : 0; + + const executionTimes = txsWithMetering.map((tx) => tx.executionTimeUs); + const sortedExecutionTimes = [...executionTimes].sort((a, b) => a - b); + + const avgExecutionTimeUs = + executionTimes.reduce((sum, time) => sum + time, 0) / + executionTimes.length; + + let minTx = txsWithMetering[0]; + let maxTx = txsWithMetering[0]; + for (const tx of txsWithMetering) { + if (tx.executionTimeUs < minTx.executionTimeUs) minTx = tx; + if (tx.executionTimeUs > maxTx.executionTimeUs) maxTx = tx; + } + + const p50ExecutionTimeUs = calculatePercentile(sortedExecutionTimes, 0.5); + const p95ExecutionTimeUs = calculatePercentile(sortedExecutionTimes, 0.95); + const p99ExecutionTimeUs = calculatePercentile(sortedExecutionTimes, 0.99); + + const gasEfficiencies = txsWithMetering + .filter((tx) => tx.gasUsed > 0n) + .map((tx) => tx.executionTimeUs / Number(tx.gasUsed)); + const avgGasEfficiency = + gasEfficiencies.length > 0 + ? gasEfficiencies.reduce((sum, eff) => sum + eff, 0) / + gasEfficiencies.length + : 0; + + const response: MeteringStatsResponse = { + timeWindowStart, + timeWindowEnd, + blockCount: validBlocks.length, + transactionCount: txsWithMetering.length, + stats: { + avgExecutionTimeUs, + minExecutionTime: { + timeUs: minTx.executionTimeUs, + txHash: minTx.txHash, + blockNumber: minTx.blockNumber, + }, + maxExecutionTime: { + timeUs: maxTx.executionTimeUs, + txHash: maxTx.txHash, + blockNumber: maxTx.blockNumber, + }, + p50ExecutionTimeUs, + p95ExecutionTimeUs, + p99ExecutionTimeUs, + avgGasEfficiency, + }, + }; + + return NextResponse.json(response, { + headers: { + "Cache-Control": "public, s-maxage=30, stale-while-revalidate=60", + }, + }); + } catch (error) { + console.error("Error fetching metering stats:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/ui/src/app/page.tsx b/ui/src/app/page.tsx index 21e257f..f7d88d3 100644 --- a/ui/src/app/page.tsx +++ b/ui/src/app/page.tsx @@ -4,6 +4,9 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import type { BlockSummary, BlocksResponse } from "./api/blocks/route"; +import type { MeteringStatsResponse } from "./api/stats/metering/route"; + +type Tab = "blocks" | "stats"; function SearchBar({ onError }: { onError: (error: string | null) => void }) { const router = useRouter(); @@ -148,10 +151,248 @@ function Card({ ); } +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function StatCard({ + label, + value, + unit, +}: { + label: string; + value: string | number; + unit?: string; +}) { + return ( + +
{label}
+
+ {typeof value === "number" ? value.toLocaleString() : value} + {unit && {unit}} +
+
+ ); +} + +function formatExecutionTime(timeUs: number): string { + if (timeUs < 1000) { + return `${timeUs.toFixed(0)}μs`; + } else if (timeUs < 1000000) { + return `${(timeUs / 1000).toFixed(2)}ms`; + } else { + return `${(timeUs / 1000000).toFixed(2)}s`; + } +} + +function MeteringStatsTab() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchStats = async () => { + try { + const response = await fetch("/api/stats/metering"); + if (response.ok) { + const data: MeteringStatsResponse = await response.json(); + setStats(data); + setError(null); + } else { + setError("Failed to fetch stats"); + } + } catch { + setError("Failed to fetch stats"); + } finally { + setLoading(false); + } + }; + + fetchStats(); + const interval = setInterval(fetchStats, 30000); + return () => clearInterval(interval); + }, []); + + if (loading) { + return ( +
+
+
+ Loading stats... +
+
+ ); + } + + if (error) { + return ( + +
+ + Error + + + {error} +
+
+ ); + } + + if (!stats || stats.transactionCount === 0) { + return ( + +
+
No metering data available
+
+ View some blocks first to populate the cache +
+
+
+ ); + } + + return ( +
+
+ + + + + +
+ + +
+

Extremes

+
+ Based on {stats.transactionCount.toLocaleString()} transactions from{" "} + {stats.blockCount} cached blocks +
+
+
+ + + + + + + + + + + {stats.stats.minExecutionTime && ( + + + + + + + )} + {stats.stats.maxExecutionTime && ( + + + + + + + )} + +
+ Type + + Execution Time + + Transaction + + Block +
+ + Fastest + + + {formatExecutionTime(stats.stats.minExecutionTime.timeUs)} + + + {stats.stats.minExecutionTime.txHash.slice(0, 16)}... + + + #{stats.stats.minExecutionTime.blockNumber.toLocaleString()} +
+ + Slowest + + + {formatExecutionTime(stats.stats.maxExecutionTime.timeUs)} + + + {stats.stats.maxExecutionTime.txHash.slice(0, 16)}... + + + #{stats.stats.maxExecutionTime.blockNumber.toLocaleString()} +
+
+
+
+ ); +} + export default function Home() { const [error, setError] = useState(null); const [blocks, setBlocks] = useState([]); const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState("blocks"); useEffect(() => { const fetchBlocks = async () => { @@ -180,6 +421,20 @@ export default function Home() { TIPS
+
+ setActiveTab("blocks")} + > + Latest Blocks + + setActiveTab("stats")} + > + Metering Stats + +
@@ -225,32 +480,43 @@ export default function Home() { )} -
-

- Latest Blocks -

+ {activeTab === "blocks" && ( +
+

+ Latest Blocks +

- - {loading ? ( -
-
-
- Loading blocks... + + {loading ? ( +
+
+
+ Loading blocks... +
-
- ) : blocks.length > 0 ? ( -
- {blocks.map((block, index) => ( - - ))} -
- ) : ( -
- No blocks available -
- )} -
-
+ ) : blocks.length > 0 ? ( +
+ {blocks.map((block, index) => ( + + ))} +
+ ) : ( +
+ No blocks available +
+ )} + +
+ )} + + {activeTab === "stats" && ( +
+

+ Transaction Metering Statistics +

+ +
+ )}
);