diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d419d0..d95d26d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,7 +165,8 @@ jobs: /internal-explorer/bundles/0x1 /api/internal-explorer/blocks \ /tips /tips/block/0x1 /tips/bundles/0x1 /api/tips/blocks \ /benchmark /benchmark/run/latest /benchmark/run-comparison/1 \ - /benchmark/load-tests/sepolia /scaling /scaling/da /scaling/guardrails /scaling/runs; do + /benchmark/load-tests/sepolia /scaling /scaling/da /scaling/guardrails /scaling/runs /scaling/runs/bench/1 \ + /scaling/runs/load/sepolia/1; do code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${route}") if [ "${code}" != "404" ]; then echo "FAIL: ${route} returned ${code}, expected 404" diff --git a/app/benchmark/components/RunList.tsx b/app/benchmark/components/RunList.tsx index 9864dc3..61e6a6f 100644 --- a/app/benchmark/components/RunList.tsx +++ b/app/benchmark/components/RunList.tsx @@ -8,7 +8,7 @@ import StatusSummary from "./StatusSummary"; import ConfigurationTags from "./ConfigurationTags"; import Tooltip from "./Tooltip"; import MachineInfo from "./MachineInfo"; -import { downloadRunInfo, downloadRunInfoCSV } from "../utils/downloadUtils"; +import { downloadRunInfo, downloadRunInfoCSV } from "../../scaling/runs/lib/downloadUtils"; import FloatingDropdown from "./FloatingDropdown"; import clsx from "classnames"; diff --git a/app/benchmark/components/RunListFilter.tsx b/app/benchmark/components/RunListFilter.tsx index 6ef5204..5d216ac 100644 --- a/app/benchmark/components/RunListFilter.tsx +++ b/app/benchmark/components/RunListFilter.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { camelToTitleCase, formatLabel } from "../../scaling/runs/lib/formatters"; import { FilterValue } from "../filter"; import { BenchmarkRunWithStatus } from "../../scaling/runs/lib/types"; -import { downloadAllRuns, downloadAllRunsCSV } from "../utils/downloadUtils"; +import { downloadAllRuns, downloadAllRunsCSV } from "../../scaling/runs/lib/downloadUtils"; import Select from "./Select"; import FloatingDropdown from "./FloatingDropdown"; import clsx from "classnames"; diff --git a/app/scaling/runs/bench/[id]/BenchRun.test.tsx b/app/scaling/runs/bench/[id]/BenchRun.test.tsx new file mode 100644 index 0000000..38e4bf9 --- /dev/null +++ b/app/scaling/runs/bench/[id]/BenchRun.test.tsx @@ -0,0 +1,71 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, it, vi } from 'vitest'; + +import fixture from '../../fixtures/synthetic.json'; +import { CHART_GROUPS } from '../../lib/chartGroups'; +import { SORTED_CHART_CONFIG } from '../../lib/metricDefinitions'; +import type { BenchmarkRuns } from '../../lib/types'; +import { BenchRun } from './BenchRun'; + +const swr = vi.hoisted(() => ({ meta: {} as object, metrics: {} as object })); +vi.mock('swr', () => ({ default: (key: unknown) => (Array.isArray(key) ? swr.metrics : swr.meta) })); + +const [run, other] = (fixture.metadata as BenchmarkRuns).runs; +const secondConfig = { + ...run, + outputDir: 'synthetic-1b', + testConfig: { ...run.testConfig, GasLimit: 200000000 }, + thresholds: { error: { 'validator/latency/new_payload': 25000000 } }, +}; +swr.meta = { data: { runs: [run, secondConfig, other] } }; +const block = (n: number, metrics: Record) => ({ BlockNumber: n, ExecutionMetrics: metrics }); +swr.metrics = { + data: [ + [block(1, { 'latency/get_payload': 0.01, 'gas/per_block': 1 }), block(2, { 'latency/get_payload': 0.02, 'gas/per_block': 2 })], + [block(1, { 'gas/per_block': 1, reth_sync_execution_execution_duration: 0.005 }), block(2, { 'gas/per_block': 2, reth_sync_execution_execution_duration: 0.006 })], + ], +}; + +it('shows every configuration of the run with thresholds, role-prefixed ones included, and raw config', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('href="/scaling/runs"'); + expect(html).toContain('2 configurations'); + expect(html).toContain('100M gas · 1000 ms blocks'); + expect(html).toContain('200M gas · 1000 ms blocks'); + expect(html).toContain('420.0'); + expect(html).not.toContain('test-synthetic-2'); + expect(html).toMatch(/latency\/get_payload.*?20\.0 ms.*?> 50\.0 ms.*?success/); + expect(html).toMatch(/validator\/latency\/new_payload.*?30\.0 ms.*?> 25\.0 ms.*?error/); + expect(html).toMatch(/200M gas · 1000 ms blocks<\/td>.*?<\/tr>/); + expect(html.match(/200M gas · 1000 ms blocks<\/td>.*?<\/tr>/)![0]).toContain('error'); + expect(html).toContain('"TransactionPayload": "synthetic-payload"'); +}); + +it('charts sequencer and validator by stage with only the first stage open', () => { + const html = renderToStaticMarkup(); + const groups = html.split(' g.match(/]*>([^<]+)/)![1].trim())).toEqual(CHART_GROUPS.map(([g]) => g)); + expect(groups.map((g) => g.startsWith(' open=""'))).toEqual([true, false, false, false, false]); + expect(groups[0]).toContain('Get Payload'); + expect(groups[0]).toMatch(/Gas Per Block.*?sequencer.*?validator/); + expect(groups[4]).toContain('Reth Sync Execution Duration'); + expect(groups[3]).toContain('No data for this stage in this run.'); +}); + +it('reports a failed metrics fetch instead of drawing partial charts', () => { + const saved = swr.metrics; + swr.metrics = { error: new Error('Failed to fetch metrics: 500') }; + const html = renderToStaticMarkup(); + swr.metrics = saved; + expect(html).toContain('Metrics could not be loaded: Failed to fetch metrics: 500'); + expect(html).not.toContain(' { + const assigned = CHART_GROUPS.flatMap(([, metrics]) => metrics); + expect(assigned.toSorted()).toEqual(SORTED_CHART_CONFIG.map(([key]) => key).toSorted()); +}); + +it('says when no run has the id', () => { + expect(renderToStaticMarkup()).toContain('No base-bench run has id missing.'); +}); diff --git a/app/scaling/runs/bench/[id]/BenchRun.tsx b/app/scaling/runs/bench/[id]/BenchRun.tsx new file mode 100644 index 0000000..9b4ecb2 --- /dev/null +++ b/app/scaling/runs/bench/[id]/BenchRun.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useState } from 'react'; +import useSWR from 'swr'; + +import { Button } from '../../../../components/ui/Button'; +import { Card } from '../../../../components/ui/Card'; +import { cn } from '../../../../components/ui/cn'; +import { EmptyState } from '../../../../components/ui/EmptyState'; +import { Text } from '../../../../components/ui/Text'; +import { Info } from '../../../components/Info'; +import { LineChart } from '../../components/LineChart'; +import { Crumbs, Section, Status, Table, TD } from '../../components/parts'; +import { CHART_GROUPS } from '../../lib/chartGroups'; +import { getDataService } from '../../lib/dataService'; +import { downloadRunInfo, downloadRunInfoCSV } from '../../lib/downloadUtils'; +import { formatValue } from '../../lib/formatters'; +import { SORTED_CHART_CONFIG } from '../../lib/metricDefinitions'; +import { getTestRunsWithStatus, thresholdSummary, type BenchmarkRunWithStatus } from '../../lib/types'; +import { toUnifiedRun } from '../../lib/unified'; + +const configLabel = ({ testConfig: c, outputDir }: BenchmarkRunWithStatus) => + [c.GasLimit && `${Number(c.GasLimit) / 1e6}M gas`, c.BlockTimeMilliseconds && `${c.BlockTimeMilliseconds} ms blocks`].filter(Boolean).join(' · ') || outputDir; + +const mgas = (gps?: number) => (gps === undefined ? '—' : (gps / 1e6).toFixed(1)); + +// Thresholds are in nanoseconds; summary latencies are in seconds. +function thresholdRows(run: BenchmarkRunWithStatus) { + const { warning = {}, error = {} } = run.thresholds ?? {}; + return [...new Set([...Object.keys(warning), ...Object.keys(error)])].map((metric) => { + const [field, role, scale] = thresholdSummary(metric) ?? []; + const value = field ? (run.result?.[`${role}Metrics`]?.[field as never] as number | undefined) : undefined; + const ns = value === undefined ? undefined : value * scale!; + const status = ns === undefined ? 'unknown' : ns > (error[metric] ?? Infinity) ? 'error' : ns > (warning[metric] ?? Infinity) ? 'warning' : 'success'; + return { metric, value, warning: warning[metric], error: error[metric], status }; + }); +} + +export function BenchRun({ id }: { id: string }) { + const { data, error } = useSWR('scaling-bench-metadata', () => getDataService().getMetadata()); + const runs = data ? getTestRunsWithStatus({ runs: data.runs.filter((r) => `${r.testConfig.BenchmarkRun}` === id) }) : []; + const first = runs[0]; + const thresholds = runs.flatMap((run) => thresholdRows(run).map((t) => ({ run, ...t }))); + + return ( +
+
+ + + + {first?.testName ?? 'Bench run'} + {first?.testDescription ? {first.testDescription} : null} + + + {first ? {first.createdAt.slice(0, 16).replace('T', ' ')} UTC · {first.sourceFile} · {runs.length} configuration{runs.length === 1 ? '' : 's'} : null} +
+ {error ? ( + + ) : !data ? ( + Loading run… + ) : !first ? ( + + ) : ( + <> +
+ + +
+ }> + + {runs.map((run) => { + const u = toUnifiedRun(run); + return ( + + + + + + + + + + + ); + })} +
{configLabel(run)}{u.workload}{mgas(run.result?.sequencerMetrics?.gasPerSecond)}{mgas(run.result?.validatorMetrics?.gasPerSecond)}{u.latency.length ? u.latency.map((l) =>
{l.label} {formatValue(l.seconds, 's')}
) : '—'}
{u.machine ?? '—'}{u.commit ?? '—'}
+ +
+ {thresholds.length ? ( + + {thresholds.map(({ run, ...t }) => ( + + + + + + + + ))} +
{t.metric}{runs.length > 1 ?

{configLabel(run)}

: null}
{t.value === undefined ? '—' : formatValue(t.value, 's')}{t.warning === undefined ? '—' : `> ${formatValue(t.warning, 'ns')}`}{t.error === undefined ? '—' : `> ${formatValue(t.error, 'ns')}`}
+ ) : No thresholds recorded.} +
+ +
+ {runs.map((run) => ( + + {runs.length > 1 ? {configLabel(run)} : null} +
{JSON.stringify(run.testConfig, null, 2)}
+
+ ))} +
+ + )} + + ); +} + +const CONFIG = new Map(SORTED_CHART_CONFIG); +const ROLES = [ + { name: 'sequencer', stroke: 'stroke-base-blue', dot: 'bg-base-blue' }, + { name: 'validator', stroke: 'stroke-bds-orange-50', dot: 'bg-bds-orange-50' }, +]; + +function Charts({ runs }: { runs: BenchmarkRunWithStatus[] }) { + const [selected, setSelected] = useState(0); + const run = runs[selected]; + const { data, error } = useSWR(['scaling-bench-metrics', run.outputDir], () => Promise.all(ROLES.map((r) => getDataService().getMetrics(run.outputDir, r.name)))); + const groups = CHART_GROUPS.map(([group, metrics]) => [group, metrics.map((key) => { + const config = CONFIG.get(key)!; + const keys = [key, ...(config.aliases ?? [])]; + const series = ROLES.map((role, i) => ({ ...role, points: (data?.[i] ?? []).flatMap((m): [number, number][] => { + const k = keys.find((k) => m.ExecutionMetrics[k] !== undefined); + return k ? [[m.BlockNumber, m.ExecutionMetrics[k]]] : []; + }) })); + return { key, config, series }; + }).filter((c) => c.series.some((s) => s.points.length))] as const); + + return ( +
+ {runs.length > 1 ? ( +
+ {runs.map((r, i) => ( + + ))} +
+ ) : null} + {error ? Metrics could not be loaded: {error instanceof Error ? error.message : String(error)} : !data ? Loading metrics… : groups.map(([group, charts], i) => ( +
+ {group} · {charts.length} chart{charts.length === 1 ? '' : 's'} + {charts.length ? ( +
+ {charts.map((c) => `block ${x}`} />)} +
+ ) : No data for this stage in this run.} +
+ ))} +
+ ); +} diff --git a/app/scaling/runs/bench/[id]/page.tsx b/app/scaling/runs/bench/[id]/page.tsx new file mode 100644 index 0000000..3b7e317 --- /dev/null +++ b/app/scaling/runs/bench/[id]/page.tsx @@ -0,0 +1,6 @@ +import { BenchRun } from './BenchRun'; + +export default async function BenchRunPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/app/scaling/runs/components/LineChart.tsx b/app/scaling/runs/components/LineChart.tsx new file mode 100644 index 0000000..2a1124d --- /dev/null +++ b/app/scaling/runs/components/LineChart.tsx @@ -0,0 +1,34 @@ +import { cn } from '../../../components/ui/cn'; +import { formatValue } from '../lib/formatters'; +import type { ChartConfig } from '../lib/types'; + +export type Series = { name: string; stroke: string; dot: string; points: [x: number, y: number][] }; + +const W = 600; +const H = 140; + +export function LineChart({ title, unit, series, xLabel }: { title: string; unit?: ChartConfig['unit']; series: Series[]; xLabel: (x: number) => string }) { + const drawn = series.filter((s) => s.points.length > 0); + const all = drawn.flatMap((s) => s.points); + if (all.length === 0) return null; + const [x0, x1, y0, y1] = all.reduce(([a, b, c, d], [x, y]) => [Math.min(a, x), Math.max(b, x), Math.min(c, y), Math.max(d, y)], [Infinity, -Infinity, 0, 0]); + const px = (x: number) => (x1 === x0 ? W / 2 : ((x - x0) / (x1 - x0)) * W); + const py = (y: number) => (y1 === y0 ? H : H - ((y - y0) / (y1 - y0)) * H); + return ( +
+
+ {title} + + {drawn.length > 1 ? drawn.map((s) => ) : null} + max {formatValue(y1, unit)} + +
+ + {drawn.map((s) => ( + `${px(x)},${py(y)}`).join(' ')} /> + ))} + + {xLabel(x0)}{xLabel(x1)} +
+ ); +} diff --git a/app/scaling/runs/components/parts.tsx b/app/scaling/runs/components/parts.tsx new file mode 100644 index 0000000..f66893e --- /dev/null +++ b/app/scaling/runs/components/parts.tsx @@ -0,0 +1,44 @@ +import Link from 'next/link'; +import type { ReactNode } from 'react'; + +import { Card } from '../../../components/ui/Card'; +import { cn } from '../../../components/ui/cn'; +import { Text } from '../../../components/ui/Text'; + +export const TD = 'px-3 py-2 align-top'; +const TH = 'px-3 py-2 text-left text-[12px] font-[500] text-bds-gray-60'; +const DOT: Record = { success: 'bg-bds-green-50', warning: 'bg-bds-yellow-50', error: 'bg-bds-red-50', fatal: 'bg-bds-red-50' }; + +export function Crumbs({ here }: { here: string }) { + return ( + + Scaling / Runs / {here} + + ); +} + +export function Section({ title, action, children }: { title: ReactNode; action?: ReactNode; children: ReactNode }) { + return ( +
+
{title}{action}
+ {children} +
+ ); +} + +export function Table({ head, children }: { head: string[]; children: ReactNode }) { + return ( + + + {head.map((h) => )} + {children} +
{h}
+
+ ); +} + +export function Status({ status }: { status: string }) { + return ( + + ); +} diff --git a/app/scaling/runs/lib/chartGroups.ts b/app/scaling/runs/lib/chartGroups.ts new file mode 100644 index 0000000..e5a0c31 --- /dev/null +++ b/app/scaling/runs/lib/chartGroups.ts @@ -0,0 +1,39 @@ +// Every metric in metricDefinitions.ts belongs to exactly one stage of block processing. +export const CHART_GROUPS: [group: string, metrics: string[]][] = [ + ['Build', [ + 'latency/get_payload', 'latency/update_fork_choice', 'latency/send_txs', 'gas/per_block', 'transactions/per_block', + 'reth_base_builder_block_built_success', 'reth_base_builder_flashblock_count', 'reth_base_builder_flashblock_count_avg', + 'reth_base_builder_total_block_built_duration_avg', 'reth_base_builder_flashblock_build_duration_avg', + 'reth_base_builder_sequencer_tx_duration_avg', 'reth_base_builder_payload_transaction_simulation_duration_avg', + 'reth_base_builder_tx_simulation_duration_avg', 'reth_base_builder_payload_num_tx_gauge', 'reth_base_builder_flashblock_gas_headroom_pct_avg', + ]], + ['State root', [ + 'reth_base_builder_state_root_calculation_duration_avg', 'reth_base_builder_state_root_calculation_duration_quantile_0_5', + 'reth_base_builder_state_root_calculation_duration_quantile_0_9', 'reth_base_builder_state_root_calculation_duration_quantile_0_99', + 'reth_base_builder_state_root_time_per_gas_ratio_quantile_0_9', + 'chain/account/updates.50-percentile', 'chain/account/hashes.50-percentile', 'chain/storage/updates.50-percentile', + ]], + ['Persist', [ + 'reth_storage_providers_database_save_blocks_total_quantile_0_9', 'reth_storage_providers_database_save_blocks_block_count_last', + 'reth_storage_providers_database_save_blocks_commit_sf_quantile_0_9', 'reth_storage_providers_database_save_blocks_commit_mdbx_quantile_0_9', + 'reth_storage_providers_database_save_blocks_write_state_quantile_0_9', 'reth_storage_providers_database_save_blocks_write_hashed_state_quantile_0_9', + 'reth_storage_providers_database_save_blocks_write_trie_updates_quantile_0_9', 'reth_storage_providers_database_save_blocks_sf_quantile_0_9', + 'reth_db_freelist', 'chain/write.50-percentile', 'chain/account/commits.50-percentile', 'chain/storage/commits.50-percentile', + 'chain/snapshot/commits.50-percentile', 'chain/triedb/commits.50-percentile', + ]], + ['Sparse trie', [ + 'reth_trie_leaves_added_quantile_0_9', 'reth_trie_branches_added_quantile_0_9', + 'reth_tree_root_sparse_trie_total_duration_histogram_quantile_0_9', 'reth_tree_root_sparse_trie_final_update_duration_histogram_quantile_0_9', + 'reth_parallel_sparse_trie_subtrie_hash_update_latency_quantile_0_9', 'reth_parallel_sparse_trie_subtrie_upper_hash_latency_quantile_0_9', + 'reth_trie_proof_task_storage_worker_idle_time_seconds_quantile_0_9', 'reth_trie_proof_task_account_worker_idle_time_seconds_quantile_0_9', + 'reth_trie_proof_task_blinded_storage_nodes_quantile_0_9', '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', + ]], + ['Validate', [ + 'latency/new_payload', 'reth_sync_execution_execution_duration_avg', 'reth_sync_block_validation_state_root_duration_avg', + 'reth_sync_state_provider_total_storage_fetch_latency_avg', 'reth_sync_state_provider_total_code_fetch_latency_avg', + 'reth_sync_state_provider_total_account_fetch_latency_avg', + 'chain/inserts.50-percentile', 'chain/account/reads.50-percentile', 'chain/storage/reads.50-percentile', + 'chain/execution.50-percentile', 'chain/validation.50-percentile', 'chain/crossvalidation.50-percentile', + ]], +]; diff --git a/app/benchmark/utils/downloadUtils.ts b/app/scaling/runs/lib/downloadUtils.ts similarity index 99% rename from app/benchmark/utils/downloadUtils.ts rename to app/scaling/runs/lib/downloadUtils.ts index 29ecbfe..5668900 100644 --- a/app/benchmark/utils/downloadUtils.ts +++ b/app/scaling/runs/lib/downloadUtils.ts @@ -1,4 +1,4 @@ -import { BenchmarkRunWithStatus } from "../../scaling/runs/lib/types"; +import { BenchmarkRunWithStatus } from "./types"; /** * Downloads run information as a JSON file diff --git a/app/scaling/runs/lib/types.ts b/app/scaling/runs/lib/types.ts index a876a4a..9dd9ee5 100644 --- a/app/scaling/runs/lib/types.ts +++ b/app/scaling/runs/lib/types.ts @@ -107,6 +107,10 @@ const statusRelatedMetrics = { "latency/new_payload": ["newPayload", "validator", 1e9], } as const; +// report-api writes threshold keys with the role first: `sequencer/latency/get_payload`. +export const thresholdSummary = (metric: string) => + statusRelatedMetrics[metric.replace(/^(sequencer|validator)\//, "") as keyof typeof statusRelatedMetrics]; + export type BenchmarkRunWithStatus = BenchmarkRun & { status: RunStatus }; // ----------------------------------------------------------------------------- @@ -289,8 +293,7 @@ export const getTestRunsWithStatus = ( ): RunStatus | undefined => { for (const [metric, threshold] of Object.entries(thresholds)) { const [statusThresholdName, statusType, scale] = - statusRelatedMetrics[metric as keyof typeof statusRelatedMetrics] ?? - []; + thresholdSummary(metric) ?? []; if (!statusThresholdName || !statusType || !scale) { // metrics not related to a summary stat are not considered for status continue; diff --git a/app/scaling/runs/lib/unified.ts b/app/scaling/runs/lib/unified.ts index 5d71fea..0f445d3 100644 --- a/app/scaling/runs/lib/unified.ts +++ b/app/scaling/runs/lib/unified.ts @@ -34,7 +34,7 @@ function fromBench(run: BenchmarkRunWithStatus): UnifiedRun { // metadata.json records no block count, so a bench run's duration is unknown. return withQualifies({ kind: 'bench', - href: `/scaling/runs/bench/${run.id}`, + href: `/scaling/runs/bench/${run.testConfig.BenchmarkRun}`, env: 'bench', network: null, timestamp: Date.parse(run.createdAt), diff --git a/app/scaling/runs/load/[network]/[timestamp]/LoadRun.test.tsx b/app/scaling/runs/load/[network]/[timestamp]/LoadRun.test.tsx new file mode 100644 index 0000000..fa75ca6 --- /dev/null +++ b/app/scaling/runs/load/[network]/[timestamp]/LoadRun.test.tsx @@ -0,0 +1,42 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, it, vi } from 'vitest'; + +import fixture from '../../../fixtures/synthetic.json'; +import { LoadRun } from './LoadRun'; + +const swr = vi.hoisted(() => ({ result: {} as object, list: {} as object })); +vi.mock('swr', () => ({ default: (key: string[]) => (key[0] === 'scaling-load-list' ? swr.list : swr.result) })); + +const [newest, middle, oldest] = fixture.loadTests; +swr.list = { data: fixture.loadTests.map(({ network, timestamp }) => ({ network, timestamp })) }; +const render = (timestamp: string) => renderToStaticMarkup(); + +it('leads with observed Mgas/s, the workload translation and the key knobs', () => { + swr.result = { data: { ...newest.result, throughput_timeseries: [{ elapsed_secs: 1, tps: 30, gps: 12e6 }, { elapsed_secs: 2, tps: 33, gps: 13e6 }] } }; + const html = render(middle.timestamp); + expect(html).toContain('href="/scaling/runs"'); + expect(html).toMatch(/12\.5<\/span> ]*>Mgas\/s observed/); + // 375M gas over 990 confirmed txs is about 378,788 gas each; 12.5M gas/s / 378,788 ≈ 33 tx/s. + expect(html).toContain('≈ 33 tx/s at this mix: transfer (75%) · swap (25%)'); + expect(html).toContain('target 15 Mgas/s'); + expect(html).toContain('10 senders'); + expect(html).toContain('4 in flight each'); + expect(html).toContain('aria-label="Gas per second"'); + expect(html).toContain('Flashblocks latency · 10 samples'); + expect(html).toMatch(/p99<\/span>.*?3\.0 s/); + expect(html).toContain('No failures recorded.'); +}); + +it('steps to the older and newer neighbours', () => { + const html = render(middle.timestamp); + expect(html).toContain(`href="/scaling/runs/load/sepolia/${oldest.timestamp}">← Previous run`); + expect(html).toContain(`href="/scaling/runs/load/sepolia/${newest.timestamp}">Next run →`); + expect(render(newest.timestamp)).toContain('Next run →'); +}); + +it('shows a fatal run without metrics', () => { + swr.result = { data: oldest.result }; + const html = render(oldest.timestamp); + expect(html).toContain('synthetic fatal error'); + expect(html).not.toContain('Mgas/s observed'); +}); diff --git a/app/scaling/runs/load/[network]/[timestamp]/LoadRun.tsx b/app/scaling/runs/load/[network]/[timestamp]/LoadRun.tsx new file mode 100644 index 0000000..7e55798 --- /dev/null +++ b/app/scaling/runs/load/[network]/[timestamp]/LoadRun.tsx @@ -0,0 +1,125 @@ +'use client'; + +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import useSWR from 'swr'; + +import { Card } from '../../../../../components/ui/Card'; +import { EmptyState } from '../../../../../components/ui/EmptyState'; +import { Text } from '../../../../../components/ui/Text'; +import { Info } from '../../../../components/Info'; +import { LineChart } from '../../../components/LineChart'; +import { Crumbs, Section, Status } from '../../../components/parts'; +import { getDataService } from '../../../lib/dataService'; +import { durationToNanos, formatDuration, formatEthFromWei, formatLoadTestTimestamp, formatTransactions, formatValue } from '../../../lib/formatters'; +import type { FlashblocksLatencyStats, LatencyStats, LoadTestResult } from '../../../lib/types'; + +const mgas = (gps: number) => (gps / 1e6).toLocaleString('en-US', { maximumFractionDigits: 1 }); +const n = (v: number) => v.toLocaleString('en-US', { maximumFractionDigits: 0 }); + +export function LoadRun({ network, timestamp }: { network: string; timestamp: string }) { + const { data: result, error } = useSWR(['scaling-load-result', network, timestamp], () => getDataService().getLoadTestResult(network, timestamp)); + const { data: list } = useSWR(['scaling-load-list', network], () => getDataService().getLoadTestList(network)); + // The list is newest first. + const at = list?.findIndex((e) => e.timestamp === timestamp) ?? -1; + const step = (i: number, label: string) => { + const e = at >= 0 ? list?.[i] : undefined; + return e ? {label} : {label}; + }; + + return ( +
+
+ + Load test · {network} · {formatLoadTestTimestamp(timestamp)} UTC + {step(at + 1, '← Previous run')}{step(at - 1, 'Next run →')} +
+ {error ? ( + + ) : !result ? ( + Loading load test… + ) : } +
+ ); +} + +function Report({ result: r }: { result: LoadTestResult }) { + const { config: c, throughput: t } = r; + const gasPerTx = t && t.total_confirmed > 0 ? r.gas.total_gas / t.total_confirmed : null; + return ( + <> + {r.error ?

{r.error}

: null} + {t ? ( + +
+ {mgas(t.gps)} Mgas/s observed + {gasPerTx && c ? ≈ {n(t.gps / gasPerTx)} tx/s at this mix: {formatTransactions(c)} : null} +
+ {c ? ( +
+ target {mgas(c.target_gps)} Mgas/s{c.duration}{c.sender_count} senders{c.in_flight_per_sender} in flight each +
{JSON.stringify(c, null, 2)}
+
+ ) : null} +
+ ) : null} + {r.throughput_timeseries && r.throughput_timeseries.length > 1 ? ( +
+ `${x.toFixed(0)} s`} + series={[{ name: 'gas/s', stroke: 'stroke-base-blue', dot: 'bg-base-blue', points: r.throughput_timeseries.map((s) => [s.elapsed_secs, s.gps]) }]} /> +
+ ) : null} + {r.block_latency ? ( +
+
+
+
+ ) : null} + {t ? ( +
+ + {formatValue(r.gas.total_gas, 'gas')} + {n(r.gas.avg_gas)} + {n(r.gas.avg_gas_price)} wei + {formatEthFromWei(r.gas.total_cost_wei)} + {n(t.total_submitted)} + {n(t.total_confirmed)} + {n(t.total_failed)} + {r.block_range ? n(r.block_range.block_count) : '—'} + +
+ ) : null} + {t ?
: null} + + ); +} + +function Stat({ label, children }: { label: string; children: ReactNode }) { + return
{label}{children}
; +} + +function Percentiles({ stats }: { stats: LatencyStats | FlashblocksLatencyStats }) { + const rows = (['min', 'p50', 'mean', 'p90', 'p95', 'p99', 'max'] as const).flatMap((k) => (k in stats ? [[k, (stats as FlashblocksLatencyStats)[k]] as const] : [])); + const max = durationToNanos(stats.max) || 1; + return ( + + {rows.map(([k, d]) => ( +
+ {k} + + {formatDuration(d)} +
+ ))} +
+ ); +} + +function Failures({ result: r }: { result: LoadTestResult }) { + const reasons = [...(r.throughput.total_reverted > 0 ? [['reverted', r.throughput.total_reverted] as const] : []), ...r.top_failure_reasons]; + if (!reasons.length) return No failures recorded.; + return ( + + {reasons.map(([reason, count]) =>
{reason}{n(count)}
)} +
+ ); +} diff --git a/app/scaling/runs/load/[network]/[timestamp]/page.tsx b/app/scaling/runs/load/[network]/[timestamp]/page.tsx new file mode 100644 index 0000000..7913331 --- /dev/null +++ b/app/scaling/runs/load/[network]/[timestamp]/page.tsx @@ -0,0 +1,6 @@ +import { LoadRun } from './LoadRun'; + +export default async function LoadRunPage({ params }: { params: Promise<{ network: string; timestamp: string }> }) { + const { network, timestamp } = await params; + return ; +}