diff --git a/.github/workflows/_build-binaries.yaml b/.github/workflows/_build-binaries.yaml index 4824d6f8..c7d75b7a 100644 --- a/.github/workflows/_build-binaries.yaml +++ b/.github/workflows/_build-binaries.yaml @@ -19,7 +19,7 @@ on: description: "Base Reth Node version to build" required: false type: string - default: "feature/load-test-benchmark" + default: "1e4a8a7" # Set minimal permissions for all jobs by default permissions: diff --git a/clients/versions.env b/clients/versions.env index 3427491a..4c12809e 100644 --- a/clients/versions.env +++ b/clients/versions.env @@ -12,7 +12,7 @@ GETH_VERSION="v1.101604.0" # Base Reth Node Configuration BASE_RETH_NODE_REPO="https://github.com/base/base" -BASE_RETH_NODE_VERSION="feature/load-test-benchmark" +BASE_RETH_NODE_VERSION="1e4a8a7" # Build Configuration # BUILD_DIR="./build" diff --git a/report/.eslintignore b/report/.eslintignore index 8c0fb650..1a62f842 100644 --- a/report/.eslintignore +++ b/report/.eslintignore @@ -1,3 +1,4 @@ .eslintrc.cjs vite.config.ts -tailwind.config.js \ No newline at end of file +tailwind.config.js +dev-mock/ \ No newline at end of file diff --git a/report/src/App.tsx b/report/src/App.tsx index bdfb046b..cef213a2 100644 --- a/report/src/App.tsx +++ b/report/src/App.tsx @@ -1,7 +1,10 @@ -import { Route, Routes } from "react-router-dom"; +import { Navigate, Route, Routes } from "react-router-dom"; import RunIndex from "./pages/RunIndex"; import RunComparison from "./pages/RunComparison"; import RedirectToLatestRun from "./pages/RedirectToLatestRun"; +import LoadTestLanding from "./pages/LoadTestLanding"; +import LoadTestAllRuns from "./pages/LoadTestAllRuns"; +import LoadTestDetail from "./pages/LoadTestDetail"; import ErrorBoundary from "./components/ErrorBoundary"; function App() { @@ -9,6 +12,16 @@ function App() { } /> + } + /> + } /> + } /> + } + /> } /> { + if (!txs || txs.length === 0) return "—"; + const total = txs.reduce((acc, t) => acc + t.weight, 0); + if (total === 0) return txs.map((t) => t.type).join(" · "); + return txs + .map((t) => `${t.type} (${Math.round((t.weight / total) * 100)}%)`) + .join(" · "); +}; + +const formatTargetGps = (gps: number): string => { + if (gps >= 1e9) return `${(gps / 1e9).toFixed(1)}B gas/s`; + if (gps >= 1e6) return `${(gps / 1e6).toFixed(0)}M gas/s`; + if (gps >= 1e3) return `${(gps / 1e3).toFixed(0)}k gas/s`; + return `${gps.toLocaleString()} gas/s`; +}; + +const buildRows = (config: LoadTestConfig): Row[][] => { + const loadShape: Row[] = [ + { label: "Senders", value: config.sender_count.toLocaleString() }, + { + label: "In-flight / sender", + value: config.in_flight_per_sender.toLocaleString(), + }, + { label: "Batch size", value: config.batch_size.toLocaleString() }, + { label: "Batch timeout", value: config.batch_timeout }, + ]; + if (config.sender_offset !== 0) { + loadShape.push({ + label: "Sender offset", + value: config.sender_offset.toLocaleString(), + }); + } + + const target: Row[] = [ + { label: "Duration", value: config.duration }, + { label: "Target gas/s", value: formatTargetGps(config.target_gps) }, + ]; + + const funding: Row[] = [ + { + label: "Funding / sender", + value: formatEthFromWeiString(config.funding_amount), + }, + ]; + const hasSwapToken = + config.transactions.some((t) => t.type === "swap") && + config.swap_token_amount && + config.swap_token_amount !== "0"; + if (hasSwapToken) { + funding.push({ + label: "Swap token amount", + value: formatEthFromWeiString(config.swap_token_amount), + }); + } + + const repro: Row[] = [{ label: "Seed", value: config.seed.toLocaleString() }]; + if (config.chain_id !== null) { + repro.push({ label: "Chain ID", value: config.chain_id.toLocaleString() }); + } + if (config.looper_contract) { + repro.push({ label: "Looper contract", value: config.looper_contract }); + } + + return [loadShape, target, funding, repro]; +}; + +const RowGroup = ({ rows }: { rows: Row[] }) => ( +
+ {rows.map((r) => ( +
+ + {r.label} + + + {r.value} + +
+ ))} +
+); + +const ConfigCard = ({ config }: ConfigCardProps) => { + const groups = buildRows(config); + const txLine = formatTransactions(config.transactions); + + return ( + +
+ {groups.map((rows, i) => ( +
+ {i > 0 &&
} + +
+ ))} +
+
+ + Workload + + {txLine} +
+
+
+ ); +}; + +export default ConfigCard; diff --git a/report/src/components/Navbar.tsx b/report/src/components/Navbar.tsx index 3082c658..927b83e2 100644 --- a/report/src/components/Navbar.tsx +++ b/report/src/components/Navbar.tsx @@ -1,21 +1,29 @@ import { Link, + useLocation, useNavigate, useParams, useSearchParams, } from "react-router-dom"; +import clsx from "clsx"; import Logo from "../assets/logo.svg"; -import { useTestMetadata } from "../utils/useDataSeries"; +import { useLoadTestList, useTestMetadata } from "../utils/useDataSeries"; import { useCallback, useMemo } from "react"; import { uniqBy } from "lodash"; import {} from "react-router-dom"; import Select from "./Select"; +import { formatLoadTestTimestamp } from "../utils/formatters"; interface ProvidedProps { urlPrefix?: string; } +const DEFAULT_LOAD_TEST_NETWORK = "sepolia"; + const Navbar = ({ urlPrefix }: ProvidedProps) => { + const location = useLocation(); + const isLoadTestsRoute = location.pathname.startsWith("/load-tests"); + const { data: allBenchmarkRuns, isLoading } = useTestMetadata(); const [searchParams] = useSearchParams(); @@ -31,7 +39,33 @@ const Navbar = ({ urlPrefix }: ProvidedProps) => { [urlPrefix, searchParams, navigate], ); - const { benchmarkRunId } = useParams(); + const { + benchmarkRunId, + network: loadTestNetwork, + timestamp: loadTestTimestamp, + } = useParams(); + + const activeLoadTestNetwork = loadTestNetwork ?? DEFAULT_LOAD_TEST_NETWORK; + + const { data: loadTestEntries, isLoading: isLoadingLoadTests } = + useLoadTestList(isLoadTestsRoute ? activeLoadTestNetwork : null); + + const navigateToLoadTestRun = useCallback( + (timestamp: string) => { + navigate({ + pathname: `/load-tests/${activeLoadTestNetwork}/${timestamp}`, + }); + }, + [activeLoadTestNetwork, navigate], + ); + + const loadTestOptions = useMemo(() => { + if (!loadTestEntries) return []; + return loadTestEntries.map((entry) => ({ + label: formatLoadTestTimestamp(entry.timestamp), + value: entry.timestamp, + })); + }, [loadTestEntries]); const latestRun = useMemo(() => { return allBenchmarkRuns?.runs.sort( @@ -80,6 +114,14 @@ const Navbar = ({ urlPrefix }: ProvidedProps) => { return optionsWithTestNum; }, [allBenchmarkRuns, latestRun]); + const tabClass = (active: boolean) => + clsx( + "px-3 py-4 text-sm border-b-2 -mb-px", + active + ? "border-blue-600 text-slate-900 font-medium" + : "border-transparent text-slate-500 hover:text-slate-900", + ); + return ( ); }; diff --git a/report/src/components/PercentileBar.tsx b/report/src/components/PercentileBar.tsx new file mode 100644 index 00000000..da84beb1 --- /dev/null +++ b/report/src/components/PercentileBar.tsx @@ -0,0 +1,60 @@ +import { ReactNode } from "react"; +import clsx from "clsx"; + +export interface PercentileBarRow { + label: string; + numericValue: number; + display: ReactNode; + emphasized?: boolean; +} + +interface PercentileBarChartProps { + rows: PercentileBarRow[]; + barColorClass?: string; +} + +const PercentileBarChart = ({ + rows, + barColorClass = "bg-blue-500", +}: PercentileBarChartProps) => { + const max = rows.reduce((m, r) => Math.max(m, r.numericValue), 0); + + return ( +
+ {rows.map((row) => { + const pct = max === 0 ? 0 : (row.numericValue / max) * 100; + return ( +
+
+ {row.label} +
+
+
+
+
+ {row.display} +
+
+ ); + })} +
+ ); +}; + +export default PercentileBarChart; diff --git a/report/src/components/StatCard.tsx b/report/src/components/StatCard.tsx new file mode 100644 index 00000000..d2687066 --- /dev/null +++ b/report/src/components/StatCard.tsx @@ -0,0 +1,48 @@ +import { ReactNode } from "react"; +import clsx from "clsx"; + +interface StatCardProps { + title: string; + children: ReactNode; + className?: string; +} + +const StatCard = ({ title, children, className }: StatCardProps) => { + return ( +
+

+ {title} +

+ {children} +
+ ); +}; + +export default StatCard; + +interface StatProps { + label: string; + value: ReactNode; + hint?: string; +} + +export const Stat = ({ label, value, hint }: StatProps) => ( +
+
{label}
+
+ {value} +
+ {hint &&
{hint}
} +
+); + +export const StatGrid = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); diff --git a/report/src/components/ThroughputChart.tsx b/report/src/components/ThroughputChart.tsx new file mode 100644 index 00000000..1c36ef33 --- /dev/null +++ b/report/src/components/ThroughputChart.tsx @@ -0,0 +1,315 @@ +import { useEffect, useRef, useState } from "react"; +import * as d3 from "d3"; +import { ThroughputSample } from "../types"; +import { formatTps, formatGps } from "../utils/formatters"; + +interface ThroughputChartProps { + samples: ThroughputSample[]; + avgTps: number; + avgGps: number; + height?: number; +} + +interface HoverState { + pixelX: number; + pixelY: number; + sample: ThroughputSample; +} + +const TPS_COLOR = "#2563eb"; +const GPS_COLOR = "#ea580c"; +const REF_COLOR = "#94a3b8"; +const MARGIN = { top: 16, right: 60, bottom: 36, left: 60 }; + +const ThroughputChart = ({ + samples, + avgTps, + avgGps, + height = 280, +}: ThroughputChartProps) => { + const containerRef = useRef(null); + const svgRef = useRef(null); + const [width, setWidth] = useState(0); + const [hover, setHover] = useState(null); + + useEffect(() => { + if (!containerRef.current) return; + const el = containerRef.current; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width ?? 0; + setWidth(w); + }); + ro.observe(el); + setWidth(el.clientWidth); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (!svgRef.current || width === 0 || samples.length === 0) return; + const svg = d3.select(svgRef.current); + svg.selectAll("*").remove(); + + const innerW = width - MARGIN.left - MARGIN.right; + const innerH = height - MARGIN.top - MARGIN.bottom; + + const maxElapsed = d3.max(samples, (d) => d.elapsed_secs) ?? 1; + const maxTps = d3.max(samples, (d) => d.tps) ?? 1; + const maxGps = d3.max(samples, (d) => d.gps) ?? 1; + + const x = d3.scaleLinear().domain([0, maxElapsed]).range([0, innerW]); + const yTps = d3 + .scaleLinear() + .domain([0, maxTps * 1.05]) + .nice() + .range([innerH, 0]); + const yGps = d3 + .scaleLinear() + .domain([0, maxGps * 1.05]) + .nice() + .range([innerH, 0]); + + const g = svg + .append("g") + .attr("transform", `translate(${MARGIN.left},${MARGIN.top})`); + + // Gridlines are drawn from the TPS axis only — the right (GPS) axis + // shares them visually because both axes start at 0 with the same tick + // count. Drawing two sets would visually clash. + g.append("g") + .attr("class", "grid") + .call( + d3 + .axisLeft(yTps) + .ticks(5) + .tickSize(-innerW) + .tickFormat(() => ""), + ) + .call((sel) => sel.select(".domain").remove()) + .selectAll("line") + .attr("stroke", "#e2e8f0") + .attr("stroke-dasharray", "2,3"); + + g.append("g") + .attr("transform", `translate(0,${innerH})`) + .call( + d3 + .axisBottom(x) + .ticks(6) + .tickFormat((d) => `${d}s`), + ) + .call((sel) => sel.select(".domain").attr("stroke", "#cbd5e1")) + .selectAll("text") + .attr("fill", "#64748b") + .attr("font-size", 11); + + g.append("g") + .call( + d3 + .axisLeft(yTps) + .ticks(5) + .tickFormat((d) => `${(d as number).toFixed(0)}`), + ) + .call((sel) => sel.select(".domain").attr("stroke", "#cbd5e1")) + .selectAll("text") + .attr("fill", TPS_COLOR) + .attr("font-size", 11); + + g.append("text") + .attr("transform", `rotate(-90)`) + .attr("x", -innerH / 2) + .attr("y", -42) + .attr("text-anchor", "middle") + .attr("font-size", 11) + .attr("fill", TPS_COLOR) + .text("TPS"); + + g.append("g") + .attr("transform", `translate(${innerW},0)`) + .call( + d3 + .axisRight(yGps) + .ticks(5) + .tickFormat((d) => `${((d as number) / 1e6).toFixed(0)}M`), + ) + .call((sel) => sel.select(".domain").attr("stroke", "#cbd5e1")) + .selectAll("text") + .attr("fill", GPS_COLOR) + .attr("font-size", 11); + + g.append("text") + .attr("transform", `rotate(-90)`) + .attr("x", -innerH / 2) + .attr("y", innerW + 48) + .attr("text-anchor", "middle") + .attr("font-size", 11) + .attr("fill", GPS_COLOR) + .text("Gas/s"); + + g.append("line") + .attr("x1", 0) + .attr("x2", innerW) + .attr("y1", yTps(avgTps)) + .attr("y2", yTps(avgTps)) + .attr("stroke", TPS_COLOR) + .attr("stroke-opacity", 0.35) + .attr("stroke-dasharray", "4,4"); + + g.append("line") + .attr("x1", 0) + .attr("x2", innerW) + .attr("y1", yGps(avgGps)) + .attr("y2", yGps(avgGps)) + .attr("stroke", GPS_COLOR) + .attr("stroke-opacity", 0.35) + .attr("stroke-dasharray", "4,4"); + + const tpsLine = d3 + .line() + .x((d) => x(d.elapsed_secs)) + .y((d) => yTps(d.tps)) + .curve(d3.curveMonotoneX); + + g.append("path") + .datum(samples) + .attr("fill", "none") + .attr("stroke", TPS_COLOR) + .attr("stroke-width", 2) + .attr("d", tpsLine); + + const gpsLine = d3 + .line() + .x((d) => x(d.elapsed_secs)) + .y((d) => yGps(d.gps)) + .curve(d3.curveMonotoneX); + + g.append("path") + .datum(samples) + .attr("fill", "none") + .attr("stroke", GPS_COLOR) + .attr("stroke-width", 2) + .attr("d", gpsLine); + + // d3.bisector handles the irregular sample spacing — array index is NOT + // proportional to elapsed time, so naive index lookup would mismap hover. + const bisect = d3.bisector( + (d) => d.elapsed_secs, + ).left; + const focusLine = g + .append("line") + .attr("y1", 0) + .attr("y2", innerH) + .attr("stroke", "#475569") + .attr("stroke-width", 1) + .attr("stroke-dasharray", "3,3") + .style("display", "none"); + const focusTpsDot = g + .append("circle") + .attr("r", 4) + .attr("fill", TPS_COLOR) + .style("display", "none"); + const focusGpsDot = g + .append("circle") + .attr("r", 4) + .attr("fill", GPS_COLOR) + .style("display", "none"); + + g.append("rect") + .attr("width", innerW) + .attr("height", innerH) + .attr("fill", "none") + .attr("pointer-events", "all") + .on("mouseleave", () => { + focusLine.style("display", "none"); + focusTpsDot.style("display", "none"); + focusGpsDot.style("display", "none"); + setHover(null); + }) + .on("mousemove", function (event) { + const [mx] = d3.pointer(event, this); + const xv = x.invert(mx); + const i = bisect(samples, xv); + const a = samples[Math.max(0, i - 1)]; + const b = samples[Math.min(samples.length - 1, i)]; + const sample = + Math.abs(xv - a.elapsed_secs) < Math.abs(xv - b.elapsed_secs) ? a : b; + const px = x(sample.elapsed_secs); + focusLine.attr("x1", px).attr("x2", px).style("display", null); + focusTpsDot + .attr("cx", px) + .attr("cy", yTps(sample.tps)) + .style("display", null); + focusGpsDot + .attr("cx", px) + .attr("cy", yGps(sample.gps)) + .style("display", null); + setHover({ + pixelX: px + MARGIN.left, + pixelY: Math.min(yTps(sample.tps), yGps(sample.gps)) + MARGIN.top, + sample, + }); + }); + }, [samples, avgTps, avgGps, width, height]); + + return ( +
+ + {hover && width > 0 && ( +
+
+ t = {hover.sample.elapsed_secs.toFixed(1)}s +
+
+ + + {formatTps(hover.sample.tps)} + +
+
+ + + {formatGps(hover.sample.gps)} + +
+
+ )} +
+ + + TPS + + + + Gas/s + + + + avg + +
+
+ ); +}; + +export default ThroughputChart; diff --git a/report/src/pages/LoadTestAllRuns.tsx b/report/src/pages/LoadTestAllRuns.tsx new file mode 100644 index 00000000..32b1a67c --- /dev/null +++ b/report/src/pages/LoadTestAllRuns.tsx @@ -0,0 +1,97 @@ +import { Link, useParams } from "react-router-dom"; +import Navbar from "../components/Navbar"; +import { useLoadTestList } from "../utils/useDataSeries"; +import { formatLoadTestTimestamp } from "../utils/formatters"; + +const DEFAULT_NETWORK = "sepolia"; + +const LoadTestAllRuns = () => { + const { network = DEFAULT_NETWORK } = useParams(); + const { data: entries, isLoading, error } = useLoadTestList(network); + + return ( +
+ +
+
+ + ← Back to latest run + +

+ All load test runs +

+

+ Network: {network} +

+
+ + {isLoading && ( +
Loading load tests…
+ )} + + {error && ( +
+ Failed to load load tests: {String(error)} +
+ )} + + {!isLoading && !error && (!entries || entries.length === 0) && ( +
+ No load test runs found for{" "} + {network}. +
+ )} + + {entries && entries.length > 0 && ( +
+ + + + + + + + + + {entries.map((entry) => ( + + + + + + ))} + +
RunNetworkDetails
+ + {formatLoadTestTimestamp(entry.timestamp)} + +
+ {entry.timestamp} +
+
+ {entry.network} + + + View → + +
+
+ )} +
+
+ ); +}; + +export default LoadTestAllRuns; diff --git a/report/src/pages/LoadTestDetail.tsx b/report/src/pages/LoadTestDetail.tsx new file mode 100644 index 00000000..9f420e8d --- /dev/null +++ b/report/src/pages/LoadTestDetail.tsx @@ -0,0 +1,243 @@ +import { Link, useParams } from "react-router-dom"; +import { useMemo } from "react"; +import Navbar from "../components/Navbar"; +import StatCard, { Stat, StatGrid } from "../components/StatCard"; +import PercentileBarChart, { + PercentileBarRow, +} from "../components/PercentileBar"; +import ThroughputChart from "../components/ThroughputChart"; +import ConfigCard from "../components/ConfigCard"; +import { useLoadTestResult } from "../utils/useDataSeries"; +import { + durationToNanos, + formatDuration, + formatEthFromWei, + formatGps, + formatLoadTestTimestamp, + formatPercent, + formatTps, + formatValue, +} from "../utils/formatters"; +import { + FlashblocksLatencyStats, + LatencyStats, + LoadTestResult, +} from "../types"; + +const buildLatencyRows = ( + stats: LatencyStats | FlashblocksLatencyStats, +): PercentileBarRow[] => { + const rows: PercentileBarRow[] = [ + { + label: "min", + numericValue: durationToNanos(stats.min), + display: formatDuration(stats.min), + }, + { + label: "p50", + numericValue: durationToNanos(stats.p50), + display: formatDuration(stats.p50), + }, + { + label: "mean", + numericValue: durationToNanos(stats.mean), + display: formatDuration(stats.mean), + }, + ]; + + if ("p90" in stats && stats.p90) { + rows.push({ + label: "p90", + numericValue: durationToNanos(stats.p90), + display: formatDuration(stats.p90), + }); + } + + rows.push( + { + label: "p95", + numericValue: durationToNanos(stats.p95), + display: formatDuration(stats.p95), + }, + { + label: "p99", + numericValue: durationToNanos(stats.p99), + display: formatDuration(stats.p99), + emphasized: true, + }, + { + label: "max", + numericValue: durationToNanos(stats.max), + display: formatDuration(stats.max), + }, + ); + + return rows; +}; + +const SwapsPerSecondHero = ({ tps }: { tps: number }) => ( +
+
+ {tps.toLocaleString(undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + })} +
+
Swaps/s
+
+); + +const SummarySection = ({ result }: { result: LoadTestResult }) => { + const submitted = result.throughput.total_submitted; + const confirmed = result.throughput.total_confirmed; + const failed = result.throughput.total_failed; + + return ( + + + + + + + + + + + + + ); +}; + +const LoadTestDetail = () => { + const { network, timestamp } = useParams(); + const { + data: result, + isLoading, + error, + } = useLoadTestResult(network, timestamp); + + const blockLatencyRows = useMemo( + () => (result ? buildLatencyRows(result.block_latency) : []), + [result], + ); + const flashblocksLatencyRows = useMemo( + () => (result ? buildLatencyRows(result.flashblocks_latency) : []), + [result], + ); + + return ( +
+ +
+
+
+ + View all runs → + +

+ {timestamp ? formatLoadTestTimestamp(timestamp) : "Load test"} +

+

+ Network: {network} + {timestamp && ( + <> + {" · "} + {timestamp} + + )} +

+
+
+ + {isLoading && ( +
Loading load test…
+ )} + + {error && ( +
+ Failed to load load test result: {String(error)} +
+ )} + + {result && ( + <> + + + {result.throughput_timeseries && + result.throughput_timeseries.length > 1 && ( + + + + )} + + {result.config && } + + + + + + + + + + + + + {result.top_failure_reasons.length === 0 ? ( +
+ No failures recorded. +
+ ) : ( +
    + {result.top_failure_reasons.map(([reason, count]) => ( +
  • + {reason} + + {count.toLocaleString()} + +
  • + ))} +
+ )} +
+ + )} +
+
+ ); +}; + +export default LoadTestDetail; diff --git a/report/src/pages/LoadTestLanding.tsx b/report/src/pages/LoadTestLanding.tsx new file mode 100644 index 00000000..3f00280c --- /dev/null +++ b/report/src/pages/LoadTestLanding.tsx @@ -0,0 +1,54 @@ +import { Navigate, useParams } from "react-router-dom"; +import Navbar from "../components/Navbar"; +import { useLoadTestList } from "../utils/useDataSeries"; + +const DEFAULT_NETWORK = "sepolia"; + +const LoadTestLanding = () => { + const { network = DEFAULT_NETWORK } = useParams(); + const { data: entries, isLoading, error } = useLoadTestList(network); + + if (!isLoading && !error && entries && entries.length > 0) { + // List endpoint returns runs sorted newest-first; take entry 0 as latest. + const latest = entries[0]; + return ( + + ); + } + + return ( +
+ +
+
+

Load Tests

+

+ Network: {network} +

+
+ + {isLoading && ( +
Loading load tests…
+ )} + + {error && ( +
+ Failed to load load tests: {String(error)} +
+ )} + + {!isLoading && !error && (!entries || entries.length === 0) && ( +
+ No load test runs found for{" "} + {network}. +
+ )} +
+
+ ); +}; + +export default LoadTestLanding; diff --git a/report/src/services/dataService.ts b/report/src/services/dataService.ts index f6163f85..458c4b66 100644 --- a/report/src/services/dataService.ts +++ b/report/src/services/dataService.ts @@ -1,6 +1,16 @@ // Unified data service that works with both static files and API servers // Since the API now emulates the static file structure, we only need one service -import { BenchmarkRuns, MetricData } from "../types"; +import { + BenchmarkRuns, + LoadTestEntry, + LoadTestResult, + MetricData, +} from "../types"; + +// Load-test endpoints live under the versioned API prefix, unlike benchmark +// data which uses the legacy unversioned `output/` paths. Centralized here so +// the call sites stay clean and a future migration is one constant change. +const LOAD_TEST_API_PREFIX = "api/v1/load-tests"; export interface DataServiceConfig { baseUrl: string; // Base URL for both static and API modes @@ -38,6 +48,37 @@ export class DataService { return await response.json(); } + + async getLoadTestList(network: string): Promise { + const response = await fetch( + `${this.baseUrl}${LOAD_TEST_API_PREFIX}/${encodeURIComponent(network)}`, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch load test list: ${response.status} ${response.statusText}`, + ); + } + + return await response.json(); + } + + async getLoadTestResult( + network: string, + timestamp: string, + ): Promise { + const response = await fetch( + `${this.baseUrl}${LOAD_TEST_API_PREFIX}/${encodeURIComponent(network)}/${encodeURIComponent(timestamp)}`, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch load test result: ${response.status} ${response.statusText}`, + ); + } + + return await response.json(); + } } // Configuration helper to determine base URL from environment diff --git a/report/src/types.ts b/report/src/types.ts index 8cd9ce92..0049f821 100644 --- a/report/src/types.ts +++ b/report/src/types.ts @@ -108,6 +108,139 @@ const statusRelatedMetrics = { export type BenchmarkRunWithStatus = BenchmarkRun & { status: RunStatus }; +// ----------------------------------------------------------------------------- +// Load tests +// ----------------------------------------------------------------------------- +// +// These types mirror the JSON written by the `base-load-test` Rust binary and +// served by report-api at: +// GET /api/v1/load-tests/:network -> LoadTestEntry[] +// GET /api/v1/load-tests/:network/:timestamp -> LoadTestResult +// +// IMPORTANT: types are hand-maintained. If the Rust schema changes, update here. +// See `upgrades.md` for planned producer-side changes (schema_version, metadata +// block, sidecar index, etc.) that would let us generate these instead. + +/** + * Mirrors Rust `std::time::Duration` as serialized by serde_json. + */ +export interface RustDuration { + secs: number; + nanos: number; +} + +export interface LatencyStats { + // NOTE: `count` is currently only present on flashblocks_latency, not + // block_latency. See upgrades.md P0 #2. Treat as optional until backend fixes. + count?: number; + min: RustDuration; + max: RustDuration; + mean: RustDuration; + p50: RustDuration; + p95: RustDuration; + p99: RustDuration; +} + +export interface FlashblocksLatencyStats extends LatencyStats { + count: number; + p90: RustDuration; +} + +export interface ThroughputStats { + total_submitted: number; + total_confirmed: number; + total_failed: number; + tps: number; + gps: number; + duration: RustDuration; +} + +export interface ThroughputPercentiles { + tps_p50: number; + tps_p90: number; + tps_p99: number; + tps_max: number; + gps_p50: number; + gps_p90: number; + gps_p99: number; + gps_max: number; +} + +export interface GasStats { + total_gas: number; + avg_gas: number; + // WARNING: This can exceed Number.MAX_SAFE_INTEGER (2^53). The Rust binary + // currently emits it as a JSON number, which silently loses precision in + // JavaScript. See upgrades.md P0 #1 — once backend stringifies it, change + // the type here to `string` and parse with BigInt at the formatting boundary. + total_cost_wei: number; + avg_gas_price: number; +} + +// Producer emits this as a JSON tuple `[reason, count]`, not an object. +// Once upgrades.md P3 #7 lands and the producer switches to `{reason, count}` +// objects, change the type here and update the page accessor. +export type FailureReason = [string, number]; + +/** + * Run parameters captured by the producer at start time. All fields mirror the + * Rust config struct verbatim; the page omits any null-valued field rather than + * showing "—" so older runs (which lack `config` entirely) and runs with mixed + * nulls present a uniform UI. + */ +export interface LoadTestConfig { + funding_amount: string; + sender_count: number; + sender_offset: number; + in_flight_per_sender: number; + batch_size: number; + batch_timeout: string; + duration: string; + target_gps: number; + seed: number; + chain_id: number | null; + transactions: Array<{ type: string; weight: number }>; + looper_contract: string | null; + swap_token_amount: string; +} + +/** + * One sample of the throughput timeseries. Producer emits one sample per + * window (≈0.5–1s apart, irregular). Plot against `elapsed_secs` directly, + * not array index, so the curve stays time-accurate. + */ +export interface ThroughputSample { + elapsed_secs: number; + tps: number; + gps: number; +} + +export interface LoadTestResult { + block_latency: LatencyStats; + flashblocks_latency: FlashblocksLatencyStats; + throughput: ThroughputStats; + throughput_percentiles: ThroughputPercentiles; + gas: GasStats; + // Element type is best-effort until upgrades.md P3 #7 lands. Empty arrays + // dominate today, so we have no live samples to verify against. + top_failure_reasons: FailureReason[]; + // Both optional for back-compat: older S3 runs predate these fields and the + // page must render without them. Sections that depend on each field are + // gated on its presence rather than rendering empty placeholders. + config?: LoadTestConfig; + throughput_timeseries?: ThroughputSample[]; +} + +/** + * One entry in the list returned by `GET /api/v1/load-tests/:network`. + * Backend sorts newest-first by timestamp string (lexicographic over the + * "YYYY-MM-DD-HH-MM-SS" format works because all components are zero-padded). + */ +export interface LoadTestEntry { + network: string; + timestamp: string; +} + export const getTestRunsWithStatus = ( runs: BenchmarkRuns, ): BenchmarkRunWithStatus[] => { diff --git a/report/src/utils/formatters.tsx b/report/src/utils/formatters.tsx index 3c39a962..68beaa58 100644 --- a/report/src/utils/formatters.tsx +++ b/report/src/utils/formatters.tsx @@ -1,4 +1,4 @@ -import { ChartConfig } from "../types"; +import { ChartConfig, RustDuration } from "../types"; const PREFIXES = { "": 1, @@ -116,6 +116,81 @@ export const formatValue = ( return value.toString(); }; +export const durationToNanos = (d: RustDuration): number => + d.secs * 1e9 + d.nanos; + +export const durationToMs = (d: RustDuration): number => + d.secs * 1000 + d.nanos / 1e6; + +export const formatDuration = (d: RustDuration): string => + formatValue(durationToNanos(d), "ns"); + +export const formatTps = (n: number): string => `${n.toFixed(1)} tx/s`; + +export const formatGps = (n: number): string => formatValue(n, "gas/s"); + +export const formatPercent = ( + numerator: number, + denominator: number, +): string => + denominator === 0 ? "—" : `${((numerator / denominator) * 100).toFixed(2)}%`; + +export const formatEthFromWei = (wei: number): string => { + // total_cost_wei may exceed Number.MAX_SAFE_INTEGER; once the producer + // stringifies it (upgrades.md P0 #1) accept `string` here and parse via BigInt. + // Today we accept the precision loss because the display only needs 6 decimals. + return `${(wei / 1e18).toFixed(6)} ETH`; +}; + +export const formatEthFromWeiString = (wei: string): string => { + // funding_amount and swap_token_amount come over the wire as decimal strings + // (Rust u128 → JSON string) so they don't lose precision in JSON. Use BigInt + // for the integer-ETH part; only the fractional remainder needs Number math + // (which is safe because it's < 1 ETH worth of wei). + try { + const w = BigInt(wei); + const oneEth = 10n ** 18n; + const whole = w / oneEth; + const remainder = w % oneEth; + if (remainder === 0n) return `${whole.toString()} ETH`; + const frac = (Number(remainder) / 1e18).toFixed(6).slice(2); + return `${whole.toString()}.${frac} ETH`; + } catch { + return `${wei} wei`; + } +}; + +const LOAD_TEST_TIMESTAMP_RE = + /^(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})$/; + +export const parseLoadTestTimestamp = (raw: string): Date | null => { + // Format produced by base-load-test: "YYYY-MM-DD-HH-MM-SS" (UTC, no zone in + // the string but the producer writes UTC). Returning null on parse failure + // lets callers degrade to showing the raw string instead of crashing. + const m = LOAD_TEST_TIMESTAMP_RE.exec(raw); + if (!m) return null; + const [, y, mo, d, h, mi, s] = m; + const ts = Date.UTC( + Number(y), + Number(mo) - 1, + Number(d), + Number(h), + Number(mi), + Number(s), + ); + return Number.isNaN(ts) ? null : new Date(ts); +}; + +export const formatLoadTestTimestamp = (raw: string): string => { + const d = parseLoadTestTimestamp(raw); + if (!d) return raw; + return Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(d); +}; + export const camelToTitleCase = (str: string) => { return str .replace(/([A-Z])/g, " $1") diff --git a/report/src/utils/useDataSeries.ts b/report/src/utils/useDataSeries.ts index 1d4ab047..24f08eca 100644 --- a/report/src/utils/useDataSeries.ts +++ b/report/src/utils/useDataSeries.ts @@ -1,5 +1,10 @@ import useSWR, { State, useSWRConfig } from "swr"; -import { BenchmarkRuns, MetricData } from "../types"; +import { + BenchmarkRuns, + LoadTestEntry, + LoadTestResult, + MetricData, +} from "../types"; import { useCallback } from "react"; import { getDataService } from "../services/dataService"; @@ -91,3 +96,46 @@ export const useMultipleDataSeries = ( errorRetryInterval: 5000, }); }; + +export const useLoadTestList = (network: string | null | undefined) => { + const fetcher = useCallback(async (): Promise => { + if (!network) { + throw new Error("network required"); + } + const dataService = getDataService(); + return await dataService.getLoadTestList(network); + }, [network]); + + return useSWR(network ? `load-tests-${network}` : null, fetcher, { + dedupingInterval: 5 * 60 * 1000, + revalidateOnFocus: true, + errorRetryCount: 3, + errorRetryInterval: 5000, + }); +}; + +export const useLoadTestResult = ( + network: string | undefined, + timestamp: string | undefined, +) => { + const fetcher = useCallback(async (): Promise => { + if (!network || !timestamp) { + throw new Error("network and timestamp required"); + } + const dataService = getDataService(); + return await dataService.getLoadTestResult(network, timestamp); + }, [network, timestamp]); + + return useSWR( + network && timestamp ? `load-test-${network}-${timestamp}` : null, + fetcher, + { + // Individual results are immutable (the file at .json never + // changes), so cache aggressively to match the backend's 12h Cache-Control. + dedupingInterval: 12 * 60 * 60 * 1000, + revalidateOnFocus: false, + errorRetryCount: 3, + errorRetryInterval: 5000, + }, + ); +};