From 5a2f6eebc0e178b0e4d7751180d3277d3f0a61d6 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Wed, 23 Sep 2026 14:38:02 -0700 Subject: [PATCH] feat(benchmark): add latest release performance dashboard --- .../benchmark/performance/[runId]/route.ts | 32 ++++ app/api/benchmark/performance/route.ts | 23 +++ app/benchmark/benchmark.css | 2 +- app/benchmark/components/BaseChart.tsx | 2 +- app/benchmark/components/ChartGrid.tsx | 8 +- app/benchmark/components/LineChart.tsx | 7 +- app/benchmark/metricDefinitions.ts | 7 - app/benchmark/page.tsx | 7 +- app/benchmark/performance/PerformanceView.tsx | 146 ++++++++++++++++++ app/benchmark/performance/data.test.ts | 56 +++++++ app/benchmark/performance/data.ts | 116 ++++++++++++++ app/benchmark/performance/page.tsx | 11 ++ app/components/AppShell.tsx | 7 + app/navigation.ts | 20 +-- deploy.config.mjs | 7 +- deploy.config.test.mjs | 6 +- 16 files changed, 419 insertions(+), 38 deletions(-) create mode 100644 app/api/benchmark/performance/[runId]/route.ts create mode 100644 app/api/benchmark/performance/route.ts create mode 100644 app/benchmark/performance/PerformanceView.tsx create mode 100644 app/benchmark/performance/data.test.ts create mode 100644 app/benchmark/performance/data.ts create mode 100644 app/benchmark/performance/page.tsx diff --git a/app/api/benchmark/performance/[runId]/route.ts b/app/api/benchmark/performance/[runId]/route.ts new file mode 100644 index 00000000..c815808b --- /dev/null +++ b/app/api/benchmark/performance/[runId]/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; + +import { surfaceEnabled } from '../../../../../deploy.config.mjs'; + +const RUN_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; +const RUN_SOURCE_BASE_URL = 'https://benchmark-results.base.org/runs'; + +export const revalidate = 300; + +export async function GET(request: Request, { params }: { params: Promise<{ runId: string }> }) { + if (!surfaceEnabled('benchmark')) return new NextResponse(null, { status: 404 }); + const { runId } = await params; + if (!RUN_ID.test(runId)) return NextResponse.json({ error: 'Invalid benchmark run ID.' }, { status: 400 }); + + const requestedArtifact = new URL(request.url).searchParams.get('artifact'); + const artifact = requestedArtifact === 'metrics-validator' + ? 'metrics-validator.json' + : requestedArtifact === 'metrics-sequencer' + ? 'metrics-sequencer.json' + : 'load-test-result.json'; + try { + const response = await fetch(`${RUN_SOURCE_BASE_URL}/${encodeURIComponent(runId)}/${artifact}`, { + next: { revalidate }, + }); + if (!response.ok) return NextResponse.json({ error: `Benchmark run source returned ${response.status}.` }, { status: 502 }); + return NextResponse.json(await response.json(), { + headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' }, + }); + } catch { + return NextResponse.json({ error: 'Benchmark run source is unavailable.' }, { status: 502 }); + } +} diff --git a/app/api/benchmark/performance/route.ts b/app/api/benchmark/performance/route.ts new file mode 100644 index 00000000..b493ce82 --- /dev/null +++ b/app/api/benchmark/performance/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; + +import { surfaceEnabled } from '../../../../deploy.config.mjs'; +import { PERFORMANCE_SOURCE_URL } from '../../../benchmark/performance/data'; + +export const revalidate = 300; + +export async function GET() { + if (!surfaceEnabled('benchmark')) { + return new NextResponse(null, { status: 404 }); + } + try { + const response = await fetch(PERFORMANCE_SOURCE_URL, { next: { revalidate } }); + if (!response.ok) { + return NextResponse.json({ error: `Benchmark result source returned ${response.status}.` }, { status: 502 }); + } + return NextResponse.json(await response.json(), { + headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' }, + }); + } catch { + return NextResponse.json({ error: 'Benchmark result source is unavailable.' }, { status: 502 }); + } +} diff --git a/app/benchmark/benchmark.css b/app/benchmark/benchmark.css index 384daae6..5abf2b56 100644 --- a/app/benchmark/benchmark.css +++ b/app/benchmark/benchmark.css @@ -14,7 +14,7 @@ * `styles.contentInner` in app/components/AppShell.tsx (padding 24px 28px 80px). */ margin: -24px -28px -80px; padding: 24px 28px 80px; - background: #f8fafc; + background: transparent; color: #333; } diff --git a/app/benchmark/components/BaseChart.tsx b/app/benchmark/components/BaseChart.tsx index 934b68f0..f6f1f6e4 100644 --- a/app/benchmark/components/BaseChart.tsx +++ b/app/benchmark/components/BaseChart.tsx @@ -21,7 +21,7 @@ const TOP_MARGIN = 20; const ASPECT_RATIO = 0.5; const LEGEND_SPACE = 20; const X_AXIS_SPACE = 60; -const Y_AXIS_SPACE = 40; +const Y_AXIS_SPACE = 75; const TITLE_SPACE = 50; const DEFAULT_MARGIN = { diff --git a/app/benchmark/components/ChartGrid.tsx b/app/benchmark/components/ChartGrid.tsx index fe4596e5..13452ee0 100644 --- a/app/benchmark/components/ChartGrid.tsx +++ b/app/benchmark/components/ChartGrid.tsx @@ -6,6 +6,9 @@ import LineChart from "./LineChart"; interface ProvidedProps { data: DataSeries[]; role: "sequencer" | "validator" | null; + xAxisDomain?: [number, number]; + xAxisLabel?: string; + showLegend?: boolean; } function resolveMetricKey( @@ -39,7 +42,7 @@ function resolveMetricKey( return primaryKey; } -const ChartGrid: React.FC = ({ data, role }: ProvidedProps) => { +const ChartGrid: React.FC = ({ data, role, xAxisDomain, xAxisLabel, showLegend }: ProvidedProps) => { return (
{SORTED_CHART_CONFIG.map(([metricKey, config]) => { @@ -62,6 +65,9 @@ const ChartGrid: React.FC = ({ data, role }: ProvidedProps) => { description: config.description, unit: config.unit, thresholds, + xAxisDomain, + xAxisLabel, + showLegend, }; return ( diff --git a/app/benchmark/components/LineChart.tsx b/app/benchmark/components/LineChart.tsx index 9c003255..abef2e6f 100644 --- a/app/benchmark/components/LineChart.tsx +++ b/app/benchmark/components/LineChart.tsx @@ -23,6 +23,7 @@ interface LineChartProps { unit?: ChartConfig["unit"]; xAxisDomain?: [number, number]; xAxisLabel?: string; + showLegend?: boolean; thresholds?: { warning?: Record; error?: Record; @@ -56,6 +57,7 @@ const LineChart: React.FC = ({ unit, xAxisDomain, xAxisLabel = "Block Number", + showLegend = true, thresholds, }) => { // Generate a unique ID for this chart @@ -471,7 +473,7 @@ const LineChart: React.FC = ({ .attr("class", "x-axis-label") .attr("text-anchor", "middle") .attr("x", dimensions.width / 2) - .attr("y", dimensions.height + 27) + .attr("y", dimensions.height + 58) .attr("font-size", 12) .attr("fill", "#333") .text(xAxisLabel); @@ -493,6 +495,7 @@ const LineChart: React.FC = ({ .style("text-anchor", "end"); // Add legend + if (showLegend) { const legend = svg .append("g") .attr("class", "legend") @@ -550,6 +553,8 @@ const LineChart: React.FC = ({ currentX += legendItemWidths[i] + spacing; // Add spacing between items }); + } + series.forEach((s, i) => { const color = s.color || d3.schemeCategory10[i % 10]; diff --git a/app/benchmark/metricDefinitions.ts b/app/benchmark/metricDefinitions.ts index 52a36b2b..9b25c0ba 100644 --- a/app/benchmark/metricDefinitions.ts +++ b/app/benchmark/metricDefinitions.ts @@ -370,12 +370,6 @@ export const CHART_CONFIG = { description: "p90 hashed trie cursor overall duration", unit: "s", }, - reth_db_freelist: { - type: "line", - title: "MDBX Freelist", - description: "MDBX freelist size", - unit: "count", - }, reth_sync_state_provider_total_storage_fetch_latency_avg: { type: "line", title: "Validator Storage Load Latency", @@ -444,7 +438,6 @@ const CHART_CONFIG_ORDER: (keyof typeof CHART_CONFIG)[] = [ "reth_trie_proof_task_blinded_account_nodes_quantile_0_9", "reth_trie_cursor_overall_duration_quantile_0_9", "reth_trie_hashed_cursor_overall_duration_quantile_0_9", - "reth_db_freelist", ]; export const SORTED_CHART_CONFIG: [string, ChartConfig][] = Object.entries( diff --git a/app/benchmark/page.tsx b/app/benchmark/page.tsx index c1cfafbb..ff860266 100644 --- a/app/benchmark/page.tsx +++ b/app/benchmark/page.tsx @@ -1,9 +1,6 @@ import { redirect } from 'next/navigation'; -import { runHref } from './routes'; - -// /benchmark has no landing page of its own: the section opens on the newest -// benchmark run, which the run view resolves client-side from the metadata. +// Open the benchmark section on its release-oriented Performance dashboard. export default function BenchmarkPage() { - redirect(runHref('latest')); + redirect('/benchmark/performance'); } diff --git a/app/benchmark/performance/PerformanceView.tsx b/app/benchmark/performance/PerformanceView.tsx new file mode 100644 index 00000000..5f0d035f --- /dev/null +++ b/app/benchmark/performance/PerformanceView.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import useSWR from 'swr'; + +import ChartGrid from '../components/ChartGrid'; +import type { BenchmarkRuns, DataSeries, LoadTestResult, MetricData, ThroughputSample } from '../types'; +import { formatGps, formatTps } from '../utils/formatters'; +import { featuredResult, performanceReleases, payloadLabel, shouldNormalizeProgress, type Cadence } from './data'; + +const fetcher = async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to load performance results (${response.status}).`); + return response.json(); +}; + +const formatDate = (value: string) => Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }).format(new Date(value)); +const COLORS = ['#0052ff', '#7c3aed', '#e11d48', '#0891b2', '#16a34a', '#d97706', '#4f46e5']; + +type Metric = 'tps' | 'gps'; +type Comparison = 'cadence' | 'payload'; +type Series = { label: string; color: string; samples: ThroughputSample[]; average: number; durationSeconds: number }; + +function ComparisonChart({ metric, series }: { metric: Metric; series: Series[] }) { + const usable = series.filter((entry) => entry.samples.length > 1); + if (usable.length === 0) return
No published time-series data for this comparison.
; + const width = 1000; + const height = 360; + const pad = { top: 24, right: 30, bottom: 38, left: 76 }; + const points = usable.flatMap((entry) => entry.samples); + const values = points.map((point) => point[metric]); + const times = points.map((point) => point.elapsed_secs); + const rawMin = Math.min(...values); + const rawMax = Math.max(...values); + const niceStep = (range: number) => { + const base = 10 ** Math.floor(Math.log10(Math.max(range, 1))); + return [1, 2, 2.5, 5, 10].map((factor) => factor * base).find((step) => range / step <= 5) ?? 10 * base; + }; + const step = niceStep(rawMax - rawMin); + const min = Math.floor(rawMin / step) * step; + const max = Math.ceil(rawMax / step) * step; + const span = Math.max(max - min, step); + const normalizeProgress = shouldNormalizeProgress(usable.map((entry) => entry.durationSeconds)); + const timeMax = normalizeProgress ? 100 : Math.max(...times); + const timeMin = 0; + const x = (value: number, entry: Series) => { + const first = entry.samples[0]?.elapsed_secs ?? 0; + const last = entry.samples[entry.samples.length - 1]?.elapsed_secs ?? entry.durationSeconds; + const scaledValue = normalizeProgress ? ((value - first) / Math.max(last - first, 1)) * 100 : value; + return pad.left + ((scaledValue - timeMin) / Math.max(timeMax - timeMin, 1)) * (width - pad.left - pad.right); + }; + const y = (value: number) => pad.top + (1 - (value - min) / span) * (height - pad.top - pad.bottom); + const label = (value: number) => metric === 'tps' ? formatTps(value) : formatGps(value); + const guides = Array.from({ length: Math.round((max - min) / step) + 1 }, (_, index) => min + index * step); + + return
+ + {usable.map((entry, index) => )} + {guides.map((guide) => {label(guide)})} + {usable.map((entry, index) => { + const line = entry.samples.map((sample, pointIndex) => `${pointIndex === 0 ? 'M' : 'L'} ${x(sample.elapsed_secs, entry)} ${y(sample[metric])}`).join(' '); + const area = `${line} L ${x(entry.samples[entry.samples.length - 1].elapsed_secs, entry)} ${height - pad.bottom} L ${x(entry.samples[0].elapsed_secs, entry)} ${height - pad.bottom} Z`; + return ; + })} + + {normalizeProgress ? [0, 25, 50, 75, 100].map((progress) => {progress}%) : <>0s{Math.round(timeMax)}s} + +
; +} + +export default function PerformanceView() { + const { data, error, isLoading } = useSWR('/api/benchmark/performance', fetcher, { revalidateOnFocus: true, dedupingInterval: 300_000 }); + const [showDetails, setShowDetails] = useState(false); + const [metric, setMetric] = useState('tps'); + const [comparison, setComparison] = useState('payload'); + const [payload, setPayload] = useState('eth-transfer-existing'); + const [cadence, setCadence] = useState(2_000); + const releases = data ? performanceReleases(data) : []; + const [selectedVersion, setSelectedVersion] = useState(null); + const release = releases.find((entry) => entry.clientVersion === selectedVersion) ?? releases[0] ?? null; + const featured = release ? featuredResult(release) : undefined; + const runIds = release?.results.map((result) => result.id).sort().join(',') ?? ''; + const { data: runResults, error: runsError } = useSWR>( + runIds ? `performance-runs:${runIds}` : null, + async () => Object.fromEntries(await Promise.all(release!.results.map(async (result) => [result.id, await fetcher(`/api/benchmark/performance/${result.id}`)]))), + { revalidateOnFocus: false, dedupingInterval: 300_000 }, + ); + + const payloads = useMemo(() => release ? [...new Set(release.results.map((result) => result.payload))].sort((a, b) => payloadLabel(a).localeCompare(payloadLabel(b))) : [], [release]); + const comparedResults = useMemo(() => { + if (!release) return []; + return comparison === 'cadence' + ? release.results.filter((result) => result.payload === payload).sort((a, b) => a.blockTimeMilliseconds - b.blockTimeMilliseconds) + : release.results.filter((result) => result.blockTimeMilliseconds === cadence).sort((a, b) => payloadLabel(a.payload).localeCompare(payloadLabel(b.payload))); + }, [release, comparison, payload, cadence]); + const metricRunIds = comparedResults.map((result) => result.id).sort().join(','); + const { data: metricResults } = useSWR>( + metricRunIds ? `performance-metrics:${metricRunIds}` : null, + async () => Object.fromEntries(await Promise.all(comparedResults.map(async (result) => [result.id, await fetcher(`/api/benchmark/performance/${result.id}?artifact=metrics-validator`)]))), + { revalidateOnFocus: false, dedupingInterval: 300_000 }, + ); + const { data: sequencerMetricResults } = useSWR>( + metricRunIds ? `performance-sequencer-metrics:${metricRunIds}` : null, + async () => Object.fromEntries(await Promise.all(comparedResults.map(async (result) => [result.id, await fetcher(`/api/benchmark/performance/${result.id}?artifact=metrics-sequencer`)]))), + { revalidateOnFocus: false, dedupingInterval: 300_000 }, + ); + const normalizeChartProgress = shouldNormalizeProgress(comparedResults.map((result) => { + const duration = runResults?.[result.id]?.throughput.duration; + return duration ? duration.secs + duration.nanos / 1e9 : 0; + })); + const makeMetricSeries = (results: Record | undefined): DataSeries[] => comparedResults.map((result, index) => { + const data = results?.[result.id] ?? []; + const firstBlock = Math.min(...data.map((sample) => sample.BlockNumber), 0); + const lastBlock = Math.max(...data.map((sample) => sample.BlockNumber), 1); + const blockSpan = Math.max(lastBlock - firstBlock, 1); + return { + data: normalizeChartProgress ? data.map((sample) => ({ ...sample, BlockNumber: ((sample.BlockNumber - firstBlock) / blockSpan) * 100 })) : data, + name: comparison === 'cadence' ? `${result.blockTimeMilliseconds}ms blocks` : payloadLabel(result.payload), + color: COLORS[index % COLORS.length], + }; + }); + const validatorMetricSeries = makeMetricSeries(metricResults); + const sequencerMetricSeries = makeMetricSeries(sequencerMetricResults); + + const series = useMemo(() => comparedResults.map((result, index) => { + const summary = runResults?.[result.id]; + return { label: comparison === 'cadence' ? `${result.blockTimeMilliseconds}ms blocks` : payloadLabel(result.payload), color: COLORS[index % COLORS.length], samples: summary?.throughput_timeseries ?? [], average: summary?.throughput[metric] ?? 0, durationSeconds: summary ? summary.throughput.duration.secs + summary.throughput.duration.nanos / 1e9 : 0 }; + }), [comparedResults, runResults, comparison, metric]); + + if (isLoading) return
; + if (error) return
Unable to load the latest performance results. {error.message}
; + if (!release) return
No complete performance results have been published yet.
; + const featuredSummary = featured ? runResults?.[featured.id] : undefined; + + return
+

Release performance

Performance

End-to-end throughput from saturated Base Sepolia snapshot benchmarks.

{releases.length > 0 && }
+

Transactions per second

{featuredSummary ?

{formatTps(featuredSummary.throughput.tps)}

:

Loading result…

}

2s blocks · ETH transfers to existing accounts

{featuredSummary &&
Gas per second{formatGps(featuredSummary.throughput.gps)}
}
Release
{release.clientVersion}
Updated
Published {formatDate(release.publishedAt)} UTC
+

Throughput comparison

Compare throughput over the measured duration.

+
{comparison === 'cadence' ?
:
}
+ {runsError &&

Some time-series data is temporarily unavailable.

} +
{series.map((entry) =>
{entry.label}{entry.average > 0 && {metric === 'tps' ? formatTps(entry.average) : formatGps(entry.average)}}
)}
+
+ {(validatorMetricSeries.some((entry) => entry.data.length > 0) || sequencerMetricSeries.some((entry) => entry.data.length > 0)) &&
{showDetails &&
{validatorMetricSeries.map((entry) =>
{entry.name}
)}
{sequencerMetricSeries.some((entry) => entry.data.length > 0) && <>

Sequencer metrics

Payload building and block-production metrics from the sequencer.

}{validatorMetricSeries.some((entry) => entry.data.length > 0) && <>

Validator metrics

Execution, storage, and validation metrics from the validator.

}
}
} + +
; +} diff --git a/app/benchmark/performance/data.test.ts b/app/benchmark/performance/data.test.ts new file mode 100644 index 00000000..30febab3 --- /dev/null +++ b/app/benchmark/performance/data.test.ts @@ -0,0 +1,56 @@ +import { FEATURED_BLOCK_TIME_MS, FEATURED_PAYLOAD, featuredResult, latestPerformanceRelease, shouldNormalizeProgress } from './data'; +import type { BenchmarkRun, BenchmarkRuns } from '../types'; + +const baseResult = (clientVersion = 'release-a', gasPerSecond = 100) => ({ + success: true, + complete: true, + clientVersion, + validatorMetrics: { gasPerSecond }, + sequencerMetrics: { gasPerSecond, forkChoiceUpdated: 0, getPayload: 0 }, +}); + +const run = (overrides: Partial = {}): BenchmarkRun => ({ + id: 'run', sourceFile: 'snapshot', outputDir: 'run', testName: 'Snapshot', testDescription: '', createdAt: '2026-09-23T20:00:00.000Z', + testConfig: { ClientVersion: 'release-a', TransactionPayload: 'eth-transfer-existing', BlockTimeMilliseconds: 2000 }, + result: baseResult(), + ...overrides, +}); + +describe('latestPerformanceRelease', () => { + it('selects only the newest release and the newest duplicate workload result', () => { + const metadata: BenchmarkRuns = { runs: [ + run({ id: 'older-release', createdAt: '2026-09-21T20:00:00.000Z' }), + run({ id: 'old-repeat', createdAt: '2026-09-23T19:00:00.000Z' }), + run({ id: 'new-repeat', createdAt: '2026-09-23T20:00:00.000Z', result: baseResult('release-a', 200) }), + run({ id: 'new-cadence', createdAt: '2026-09-23T20:01:00.000Z', testConfig: { ClientVersion: 'release-b', TransactionPayload: 'eth-transfer-existing', BlockTimeMilliseconds: 200 }, result: baseResult('release-b', 300) }), + ] }; + const release = latestPerformanceRelease(metadata)!; + expect(release.clientVersion).toBe('release-b'); + expect(release.results).toEqual([expect.objectContaining({ id: 'new-cadence', validatorGasPerSecond: 300 })]); + }); + + it('does not use an older release to fill a missing latest-release workload', () => { + const release = latestPerformanceRelease({ runs: [ + run({ id: 'older-2s', createdAt: '2026-09-22T00:00:00.000Z' }), + run({ id: 'latest-200ms', createdAt: '2026-09-23T00:00:00.000Z', testConfig: { ClientVersion: 'release-b', TransactionPayload: FEATURED_PAYLOAD, BlockTimeMilliseconds: 200 }, result: baseResult('release-b') }), + ] })!; + expect(featuredResult(release)).toBeUndefined(); + expect(release.results).toHaveLength(1); + }); + + it('ignores incomplete results and finds the requested featured workload', () => { + const release = latestPerformanceRelease({ runs: [ + run({ id: 'incomplete', createdAt: '2026-09-24T00:00:00.000Z', result: { ...baseResult(), complete: false } }), + run(), + ] })!; + expect(featuredResult(release)).toMatchObject({ payload: FEATURED_PAYLOAD, blockTimeMilliseconds: FEATURED_BLOCK_TIME_MS }); + }); +}); + + +describe('shouldNormalizeProgress', () => { + it('only normalizes materially different durations', () => { + expect(shouldNormalizeProgress([119, 120])).toBe(false); + expect(shouldNormalizeProgress([60, 120])).toBe(true); + }); +}); diff --git a/app/benchmark/performance/data.ts b/app/benchmark/performance/data.ts new file mode 100644 index 00000000..0b0907b1 --- /dev/null +++ b/app/benchmark/performance/data.ts @@ -0,0 +1,116 @@ +import type { BenchmarkRun, BenchmarkRuns } from '../types'; + +export const PERFORMANCE_SOURCE_URL = 'https://benchmark-results.base.org/aggregate/metadata.json'; +export const FEATURED_PAYLOAD = 'eth-transfer-existing'; +export const FEATURED_BLOCK_TIME_MS = 2_000; + +export type Cadence = 200 | 2_000; + +export type PerformanceResult = { + id: string; + payload: string; + blockTimeMilliseconds: Cadence; + validatorGasPerSecond: number; + createdAt: string; +}; + +export type PerformanceRelease = { + clientVersion: string; + publishedAt: string; + results: PerformanceResult[]; +}; + +export const payloadLabel = (payload: string): string => { + const labels: Record = { + 'eth-transfer-existing': 'ETH transfer — existing account', + 'eth-transfer-fresh': 'ETH transfer — new account', + 'b20-transfer-existing': 'B20 transfer — existing account', + 'b20-transfer-fresh': 'B20 transfer — new account', + 'calldata-random-16k': 'Calldata — random 16 KiB', + 'blake2f-rounds-50000': 'BLAKE2f — 50k rounds', + 'blake2f-rounds-200000': 'BLAKE2f — 200k rounds', + }; + return labels[payload] ?? payload.replace(/-/g, ' '); +}; + +const timestamp = (value: string): number => { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY; +}; + +const toResult = (run: BenchmarkRun): PerformanceResult | null => { + const blockTime = Number(run.testConfig.BlockTimeMilliseconds); + const payload = run.testConfig.TransactionPayload; + const gasPerSecond = run.result?.validatorMetrics?.gasPerSecond; + if ( + !run.result?.success || + !run.result.complete || + (blockTime !== 200 && blockTime !== 2_000) || + typeof payload !== 'string' || + typeof gasPerSecond !== 'number' || + !Number.isFinite(gasPerSecond) || + !run.result.clientVersion + ) { + return null; + } + return { + id: run.id, + payload, + blockTimeMilliseconds: blockTime, + validatorGasPerSecond: gasPerSecond, + createdAt: run.createdAt, + }; +}; + +/** Returns every published client-version cohort, newest first. */ +export const performanceReleases = (metadata: BenchmarkRuns): PerformanceRelease[] => { + // A versioned aggregate contains both version pages and `--latest` aliases. + // Read the former to keep historical releases selectable without duplicate runs. + const versionPageRuns = metadata.runs.filter((run) => + typeof run.testConfig.BenchmarkRun === 'string' && !run.testConfig.BenchmarkRun.endsWith('--latest'), + ); + const hasLatestAlias = metadata.runs.some((run) => + typeof run.testConfig.BenchmarkRun === 'string' && run.testConfig.BenchmarkRun.endsWith('--latest'), + ); + const sourceRuns = hasLatestAlias ? versionPageRuns : metadata.runs; + const candidates = sourceRuns + .map((run) => ({ run, result: toResult(run) })) + .filter((candidate): candidate is { run: BenchmarkRun; result: PerformanceResult } => candidate.result !== null); + const byVersion = new Map }>(); + for (const { run, result } of candidates) { + const clientVersion = run.result!.clientVersion!; + const release = byVersion.get(clientVersion) ?? { publishedAt: Number.NEGATIVE_INFINITY, results: new Map() }; + release.publishedAt = Math.max(release.publishedAt, timestamp(result.createdAt)); + const key = `${result.payload}:${result.blockTimeMilliseconds}`; + const previous = release.results.get(key); + if (!previous || timestamp(result.createdAt) >= timestamp(previous.createdAt)) release.results.set(key, result); + byVersion.set(clientVersion, release); + } + return [...byVersion.entries()] + .map(([clientVersion, release]) => ({ + clientVersion, + publishedAt: new Date(release.publishedAt).toISOString(), + results: [...release.results.values()].sort((left, right) => + payloadLabel(left.payload).localeCompare(payloadLabel(right.payload)) || left.blockTimeMilliseconds - right.blockTimeMilliseconds, + ), + })) + .sort((left, right) => timestamp(right.publishedAt) - timestamp(left.publishedAt) || right.clientVersion.localeCompare(left.clientVersion)); +}; + +/** Selects the most recently published client-version cohort. */ +export const latestPerformanceRelease = (metadata: BenchmarkRuns): PerformanceRelease | null => + performanceReleases(metadata)[0] ?? null; + +/** Normalise elapsed time only when published test durations materially differ. */ +export const shouldNormalizeProgress = (durationsSeconds: number[]): boolean => { + const valid = durationsSeconds.filter((duration) => Number.isFinite(duration) && duration > 0); + if (valid.length < 2) return false; + const shortest = Math.min(...valid); + const longest = Math.max(...valid); + return (longest - shortest) / longest > 0.02; +}; + +export const featuredResult = (release: PerformanceRelease): PerformanceResult | undefined => + release.results.find( + (result) => result.payload === FEATURED_PAYLOAD && result.blockTimeMilliseconds === FEATURED_BLOCK_TIME_MS, + ); diff --git a/app/benchmark/performance/page.tsx b/app/benchmark/performance/page.tsx new file mode 100644 index 00000000..4756d55a --- /dev/null +++ b/app/benchmark/performance/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; +import PerformanceView from './PerformanceView'; + +export const metadata: Metadata = { + title: 'Performance · Base Chain', + description: 'Latest Base snapshot benchmark throughput across transaction payloads and block cadences.', +}; + +export default function PerformancePage() { + return ; +} diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index 44714348..6e4a858f 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -252,6 +252,13 @@ function NavGlyph({ name }: NavGlyphProps) { ); + case 'performance': + return ( + + + + + ); case 'runs': return ( diff --git a/app/navigation.ts b/app/navigation.ts index d6e3f90e..ae052cd7 100644 --- a/app/navigation.ts +++ b/app/navigation.ts @@ -2,7 +2,7 @@ import { BENCHMARK_ENABLED } from './benchmark/flag'; import { EXPLORER_ENABLED, EXPLORER_LABEL } from './internal-explorer/flag'; import { demoBreadcrumb } from './vibenet/demos/catalogue'; -export type NavIcon = 'home' | 'snapshots' | 'vibenet' | 'overview' | 'demos' | 'faucet' | 'explorer' | 'internal-explorer' | 'benchmark' | 'runs' | 'loadtest'; +export type NavIcon = 'home' | 'snapshots' | 'vibenet' | 'overview' | 'demos' | 'faucet' | 'explorer' | 'internal-explorer' | 'benchmark' | 'performance' | 'runs' | 'loadtest'; export type NavChild = { label: string; @@ -38,22 +38,10 @@ export const NAV_ITEMS: NavItem[] = [ ...(EXPLORER_ENABLED ? [{ label: EXPLORER_LABEL, href: '/internal-explorer', icon: 'internal-explorer', enabled: true } as NavItem] : []), - // Benchmark is internal-only; present only in the internal build target - // (deploy.config.mjs). See app/benchmark/flag.ts. The two children were the - // report's own in-page tab bar upstream. + // Performance is the sole user-facing benchmark surface. Raw report routes + // remain available by URL, but do not appear in the primary navigation. ...(BENCHMARK_ENABLED - ? [ - { - label: 'Benchmark', - href: '/benchmark', - icon: 'benchmark', - enabled: true, - children: [ - { label: 'Benchmarks', href: '/benchmark/run', icon: 'runs' }, - { label: 'Load Tests', href: '/benchmark/load-tests', icon: 'loadtest' }, - ], - } as NavItem, - ] + ? [{ label: 'Performance', href: '/benchmark/performance', icon: 'benchmark', enabled: true } as NavItem] : []), ]; diff --git a/deploy.config.mjs b/deploy.config.mjs index d5bfa2a6..c1af2ad9 100644 --- a/deploy.config.mjs +++ b/deploy.config.mjs @@ -35,11 +35,12 @@ export const SURFACES = { apiPrefixes: ['/api/internal-explorer', '/api/tips'], targets: ['internal'], }, - // No apiPrefixes: the benchmark UI calls the report API straight from the - // browser (NEXT_PUBLIC_BENCHMARK_API_BASE_URL) instead of proxying through a - // route handler here, so this app serves no /api path for it. + // The existing benchmark reports use a browser-visible report API. Performance + // additionally uses a same-origin route to proxy the CORS-restricted public + // aggregate metadata source. benchmark: { routePrefixes: ['/benchmark'], + apiPrefixes: ['/api/benchmark'], targets: ['internal'], }, }; diff --git a/deploy.config.test.mjs b/deploy.config.test.mjs index 05e74433..96f81e59 100644 --- a/deploy.config.test.mjs +++ b/deploy.config.test.mjs @@ -33,9 +33,9 @@ describe('deploy.config', () => { it('reports the disabled route + api prefixes and subtree globs', async () => { const c = await loadWithTarget('external'); expect(c.disabledRoutePrefixes()).toEqual(['/internal-explorer', '/tips', '/benchmark']); - // Benchmark contributes no api prefix: it calls the report API directly - // from the browser rather than through a route handler in this app. - expect(c.disabledApiPrefixes()).toEqual(['/api/internal-explorer', '/api/tips']); + // Performance uses a same-origin benchmark proxy, which must remain + // unavailable in the external build with the benchmark UI itself. + expect(c.disabledApiPrefixes()).toEqual(['/api/internal-explorer', '/api/tips', '/api/benchmark']); expect(c.disabledRouteGlobs()).toEqual([ '/internal-explorer', '/internal-explorer/**',