diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d52e8f8..1d419d07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,7 +165,7 @@ 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; do + /benchmark/load-tests/sepolia /scaling /scaling/da /scaling/guardrails /scaling/runs; 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/BaseChart.tsx b/app/benchmark/components/BaseChart.tsx index 934b68f0..15b1c521 100644 --- a/app/benchmark/components/BaseChart.tsx +++ b/app/benchmark/components/BaseChart.tsx @@ -1,6 +1,6 @@ import React, { useRef, useEffect, useState } from "react"; import * as d3 from "d3"; -import { MetricData } from "../types"; +import { MetricData } from "../../scaling/runs/lib/types"; interface BaseChartProps { data: MetricData[]; diff --git a/app/benchmark/components/BenchmarkToolbar.tsx b/app/benchmark/components/BenchmarkToolbar.tsx index 6ca7b679..0f2c79b0 100644 --- a/app/benchmark/components/BenchmarkToolbar.tsx +++ b/app/benchmark/components/BenchmarkToolbar.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useLoadTestList, useTestMetadata } from "../utils/useDataSeries"; import { uniqBy } from "../utils/collections"; -import { formatLoadTestTimestamp } from "../utils/formatters"; +import { formatLoadTestTimestamp } from "../../scaling/runs/lib/formatters"; import { loadTestHref } from "../routes"; import Select from "./Select"; diff --git a/app/benchmark/components/ChartGrid.tsx b/app/benchmark/components/ChartGrid.tsx index fe4596e5..0a0042ff 100644 --- a/app/benchmark/components/ChartGrid.tsx +++ b/app/benchmark/components/ChartGrid.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { SORTED_CHART_CONFIG } from "../metricDefinitions"; -import { DataSeries } from "../types"; +import { SORTED_CHART_CONFIG } from "../../scaling/runs/lib/metricDefinitions"; +import { DataSeries } from "../../scaling/runs/lib/types"; import LineChart from "./LineChart"; interface ProvidedProps { diff --git a/app/benchmark/components/ChartSelector.tsx b/app/benchmark/components/ChartSelector.tsx index 585e88b8..cc465fec 100644 --- a/app/benchmark/components/ChartSelector.tsx +++ b/app/benchmark/components/ChartSelector.tsx @@ -1,13 +1,13 @@ "use client"; import { useEffect, useMemo, useRef } from "react"; -import { BenchmarkRuns } from "../types"; +import { BenchmarkRuns } from "../../scaling/runs/lib/types"; import { deepEqual } from "../utils/collections"; import { camelToTitleCase, formatValue, formatLabel, -} from "../utils/formatters"; +} from "../../scaling/runs/lib/formatters"; import { interpolateWarm } from "d3"; import { useBenchmarkFilters } from "../hooks/useBenchmarkFilters"; import Link from "next/link"; diff --git a/app/benchmark/components/ConfigCard.test.ts b/app/benchmark/components/ConfigCard.test.ts index 38a3eacf..b6545691 100644 --- a/app/benchmark/components/ConfigCard.test.ts +++ b/app/benchmark/components/ConfigCard.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; -import { buildRows, formatTransactions } from "./ConfigCard"; -import { LoadTestConfig } from "../types"; +import { buildRows } from "./ConfigCard"; +import { formatTransactions } from "../../scaling/runs/lib/formatters"; +import { LoadTestConfig } from "../../scaling/runs/lib/types"; describe("formatTransactions", () => { it("formats a weighted transaction mix", () => { diff --git a/app/benchmark/components/ConfigCard.tsx b/app/benchmark/components/ConfigCard.tsx index 8055b925..3ee72eb8 100644 --- a/app/benchmark/components/ConfigCard.tsx +++ b/app/benchmark/components/ConfigCard.tsx @@ -1,5 +1,5 @@ -import { LoadTestConfig } from "../types"; -import { formatEthFromWeiString } from "../utils/formatters"; +import { LoadTestConfig } from "../../scaling/runs/lib/types"; +import { formatEthFromWeiString, formatTransactions } from "../../scaling/runs/lib/formatters"; import StatCard from "./StatCard"; interface ConfigCardProps { @@ -11,30 +11,6 @@ interface Row { value: string; } -const percent = (value: number): string => `${Math.round(value * 100)}%`; - -export const formatTransactions = ( - config: Pick, -): string => { - const txs = config.transactions; - 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(" · "); - - const freshRecipientRatio = config.fresh_recipient_ratio ?? 0; - return txs - .map((t) => { - if (t.type === "transfer" && freshRecipientRatio >= 1) { - return `account-create (${percent(t.weight / total)})`; - } - if (t.type === "transfer" && freshRecipientRatio > 0) { - return `${t.type} (${percent(t.weight / total)}, ${percent(freshRecipientRatio)} account-create)`; - } - return `${t.type} (${percent(t.weight / total)})`; - }) - .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`; diff --git a/app/benchmark/components/ConfigurationTags.tsx b/app/benchmark/components/ConfigurationTags.tsx index 5568120b..72479abb 100644 --- a/app/benchmark/components/ConfigurationTags.tsx +++ b/app/benchmark/components/ConfigurationTags.tsx @@ -1,5 +1,5 @@ -import { formatValue, formatLabel } from "../utils/formatters"; -import { camelToTitleCase } from "../utils/formatters"; +import { formatValue, formatLabel } from "../../scaling/runs/lib/formatters"; +import { camelToTitleCase } from "../../scaling/runs/lib/formatters"; interface ConfigurationTagsProps { testConfig: Record; diff --git a/app/benchmark/components/LineChart.tsx b/app/benchmark/components/LineChart.tsx index 9c003255..2c8ddc80 100644 --- a/app/benchmark/components/LineChart.tsx +++ b/app/benchmark/components/LineChart.tsx @@ -7,9 +7,9 @@ import React, { useMemo, } from "react"; import * as d3 from "d3"; -import { DataSeries, MetricData, ChartConfig } from "../types"; +import { DataSeries, MetricData, ChartConfig } from "../../scaling/runs/lib/types"; import BaseChart from "./BaseChart"; -import { formatValue, formatLabel } from "../utils/formatters"; +import { formatValue, formatLabel } from "../../scaling/runs/lib/formatters"; import calculateTooltipLayout from "../hooks/useTooltipLayout"; import useChartHoverSync, { ChartHoverEvent } from "../hooks/useChartHoverSync"; import { deepEqual } from "../utils/collections"; diff --git a/app/benchmark/components/MachineInfo.tsx b/app/benchmark/components/MachineInfo.tsx index e36723fe..cfacfb90 100644 --- a/app/benchmark/components/MachineInfo.tsx +++ b/app/benchmark/components/MachineInfo.tsx @@ -1,4 +1,4 @@ -import { MachineInfo as MachineInfoType } from "../types"; +import { MachineInfo as MachineInfoType } from "../../scaling/runs/lib/types"; interface MachineInfoProps { machineInfo?: MachineInfoType; diff --git a/app/benchmark/components/RunList.tsx b/app/benchmark/components/RunList.tsx index d69cda88..9864dc3d 100644 --- a/app/benchmark/components/RunList.tsx +++ b/app/benchmark/components/RunList.tsx @@ -1,8 +1,8 @@ import { groupBy } from "../utils/collections"; import { useState } from "react"; -import { formatValue, MetricValue } from "../utils/formatters"; +import { formatValue, MetricValue } from "../../scaling/runs/lib/formatters"; import ThresholdDisplay from "../views/ThresholdDisplay"; -import { BenchmarkRunWithStatus } from "../types"; +import { BenchmarkRunWithStatus } from "../../scaling/runs/lib/types"; import StatusBadge from "./StatusBadge"; import StatusSummary from "./StatusSummary"; import ConfigurationTags from "./ConfigurationTags"; diff --git a/app/benchmark/components/RunListFilter.tsx b/app/benchmark/components/RunListFilter.tsx index 9f7a3c3f..6ef5204d 100644 --- a/app/benchmark/components/RunListFilter.tsx +++ b/app/benchmark/components/RunListFilter.tsx @@ -2,9 +2,9 @@ import Link from "next/link"; import { useState } from "react"; -import { camelToTitleCase, formatLabel } from "../utils/formatters"; +import { camelToTitleCase, formatLabel } from "../../scaling/runs/lib/formatters"; import { FilterValue } from "../filter"; -import { BenchmarkRunWithStatus } from "../types"; +import { BenchmarkRunWithStatus } from "../../scaling/runs/lib/types"; import { downloadAllRuns, downloadAllRunsCSV } from "../utils/downloadUtils"; import Select from "./Select"; import FloatingDropdown from "./FloatingDropdown"; diff --git a/app/benchmark/components/ThroughputChart.tsx b/app/benchmark/components/ThroughputChart.tsx index 1c36ef33..5c1a0625 100644 --- a/app/benchmark/components/ThroughputChart.tsx +++ b/app/benchmark/components/ThroughputChart.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import * as d3 from "d3"; -import { ThroughputSample } from "../types"; -import { formatTps, formatGps } from "../utils/formatters"; +import { ThroughputSample } from "../../scaling/runs/lib/types"; +import { formatTps, formatGps } from "../../scaling/runs/lib/formatters"; interface ThroughputChartProps { samples: ThroughputSample[]; diff --git a/app/benchmark/filter.test.ts b/app/benchmark/filter.test.ts index 44577085..a72023f1 100644 --- a/app/benchmark/filter.test.ts +++ b/app/benchmark/filter.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { getBenchmarkVariables } from "./filter"; -import { BenchmarkRun } from "./types"; +import { BenchmarkRun } from "../scaling/runs/lib/types"; // Sample data for testing - This data now aligns with the ActualBenchmarkRun type const sampleRuns: BenchmarkRun[] = [ diff --git a/app/benchmark/hooks/useBenchmarkFilters.ts b/app/benchmark/hooks/useBenchmarkFilters.ts index e6db59fd..004c6095 100644 --- a/app/benchmark/hooks/useBenchmarkFilters.ts +++ b/app/benchmark/hooks/useBenchmarkFilters.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from "react"; import { deepEqual } from "../utils/collections"; -import { type BenchmarkRun } from "../types"; +import { type BenchmarkRun } from "../../scaling/runs/lib/types"; import { useSearchParamsState } from "../utils/useSearchParamsState"; import { getBenchmarkVariables } from "../filter"; type FilterValue = string | number | boolean; diff --git a/app/benchmark/utils/downloadUtils.ts b/app/benchmark/utils/downloadUtils.ts index 7bd693f3..29ecbfed 100644 --- a/app/benchmark/utils/downloadUtils.ts +++ b/app/benchmark/utils/downloadUtils.ts @@ -1,4 +1,4 @@ -import { BenchmarkRunWithStatus } from "../types"; +import { BenchmarkRunWithStatus } from "../../scaling/runs/lib/types"; /** * Downloads run information as a JSON file diff --git a/app/benchmark/utils/useDataSeries.ts b/app/benchmark/utils/useDataSeries.ts index 24f08eca..b69157ca 100644 --- a/app/benchmark/utils/useDataSeries.ts +++ b/app/benchmark/utils/useDataSeries.ts @@ -4,9 +4,9 @@ import { LoadTestEntry, LoadTestResult, MetricData, -} from "../types"; +} from "../../scaling/runs/lib/types"; import { useCallback } from "react"; -import { getDataService } from "../services/dataService"; +import { getDataService } from "../../scaling/runs/lib/dataService"; // Fetch metrics data using the configured data service export const fetchMetrics = async ( diff --git a/app/benchmark/views/LoadTestAllRuns.tsx b/app/benchmark/views/LoadTestAllRuns.tsx index 0747ad1a..51e66426 100644 --- a/app/benchmark/views/LoadTestAllRuns.tsx +++ b/app/benchmark/views/LoadTestAllRuns.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useLoadTestList } from "../utils/useDataSeries"; -import { formatLoadTestTimestamp } from "../utils/formatters"; +import { formatLoadTestTimestamp } from "../../scaling/runs/lib/formatters"; import { loadTestHref, loadTestsHref } from "../routes"; interface ProvidedProps { diff --git a/app/benchmark/views/LoadTestDetail.tsx b/app/benchmark/views/LoadTestDetail.tsx index 754e7722..ab38af40 100644 --- a/app/benchmark/views/LoadTestDetail.tsx +++ b/app/benchmark/views/LoadTestDetail.tsx @@ -15,13 +15,13 @@ import { formatGpsVerbose, formatLoadTestTimestamp, formatTps, -} from "../utils/formatters"; +} from "../../scaling/runs/lib/formatters"; import { BlockRange, FlashblocksLatencyStats, LatencyStats, LoadTestResult, -} from "../types"; +} from "../../scaling/runs/lib/types"; import { LoadTestRunSelect } from "../components/BenchmarkToolbar"; import { loadTestAllHref } from "../routes"; diff --git a/app/benchmark/views/RunComparison.tsx b/app/benchmark/views/RunComparison.tsx index 5d416956..7e77467f 100644 --- a/app/benchmark/views/RunComparison.tsx +++ b/app/benchmark/views/RunComparison.tsx @@ -7,7 +7,7 @@ import ChartSelector, { } from "../components/ChartSelector"; import ChartGrid from "../components/ChartGrid"; import { useTestMetadata, useMultipleDataSeries } from "../utils/useDataSeries"; -import { DataSeries } from "../types"; +import { DataSeries } from "../../scaling/runs/lib/types"; import { BenchmarkRunSelect } from "../components/BenchmarkToolbar"; import DataSourceError from "../components/DataSourceError"; import { runComparisonHref } from "../routes"; diff --git a/app/benchmark/views/RunIndex.tsx b/app/benchmark/views/RunIndex.tsx index 6c5d522b..4bcf3c85 100644 --- a/app/benchmark/views/RunIndex.tsx +++ b/app/benchmark/views/RunIndex.tsx @@ -4,7 +4,7 @@ import { useTestMetadata } from "../utils/useDataSeries"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getBenchmarkVariables } from "../filter"; import RunList from "../components/RunList"; -import { BenchmarkRuns, getTestRunsWithStatus } from "../types"; +import { BenchmarkRuns, getTestRunsWithStatus } from "../../scaling/runs/lib/types"; import { groupBy } from "../utils/collections"; import RunListFilter from "../components/RunListFilter"; import { BenchmarkRunSelect } from "../components/BenchmarkToolbar"; diff --git a/app/scaling/loadSnapshot.tsx b/app/scaling/loadSnapshot.tsx index a0cf43b3..c645c856 100644 --- a/app/scaling/loadSnapshot.tsx +++ b/app/scaling/loadSnapshot.tsx @@ -47,7 +47,7 @@ export function Unavailable({ loaded }: { loaded: Exclude; title: ReactNode; crumb?: string }) { +export function Header({ loaded, title, crumb, action }: { loaded: Extract; title: ReactNode; crumb?: string; action?: ReactNode }) { const { snapshot, ageDays, stale } = loaded; return (
@@ -55,6 +55,7 @@ export function Header({ loaded, title, crumb }: { loaded: ExtractScaling / {crumb} ) : null} {title} + {action} As of {formatTime(snapshot.collectedAt)} ({ageDays === 0 ? 'today' : `${ageDays}d old`}) · manually refreshed snapshot, not live data. {snapshot.context} diff --git a/app/scaling/page.test.tsx b/app/scaling/page.test.tsx index 2fd33466..d442d718 100644 --- a/app/scaling/page.test.tsx +++ b/app/scaling/page.test.tsx @@ -17,6 +17,7 @@ it('links the first screen to five detail pages instead of listing metric rows', const html = renderToStaticMarkup(await ScalingPage()); for (const page of ['da', 'execution', 'state', 'networking', 'guardrails']) expect(html).toContain(`href="/scaling/${page}"`); expect(html).not.toContain('Blob utilization'); + expect(html).toContain('href="/scaling/runs"'); }); it('shows every metric row on its detail page', async () => { diff --git a/app/scaling/page.tsx b/app/scaling/page.tsx index 9889be58..a158d883 100644 --- a/app/scaling/page.tsx +++ b/app/scaling/page.tsx @@ -18,7 +18,7 @@ export default async function ScalingPage() { const { snapshot } = loaded; return (
-
+
Runs →} /> diff --git a/app/scaling/runs/fixtures/synthetic.json b/app/scaling/runs/fixtures/synthetic.json new file mode 100644 index 00000000..2312e370 --- /dev/null +++ b/app/scaling/runs/fixtures/synthetic.json @@ -0,0 +1,70 @@ +{ + "metadata": { + "runs": [ + { + "id": "test-synthetic-1", + "sourceFile": "synthetic.yml", + "testName": "Synthetic bench run", + "testDescription": "Invented values for tests.", + "outputDir": "synthetic-1", + "createdAt": "2026-01-07T12:00:00Z", + "testConfig": { "BenchmarkRun": "test-synthetic-1", "BlockTimeMilliseconds": 1000, "GasLimit": 100000000, "TransactionPayload": "synthetic-payload" }, + "machineInfo": { "type": "synthetic.large", "provider": "", "region": "", "fileSystem": "ext4" }, + "thresholds": { "warning": { "latency/get_payload": 50000000 } }, + "result": { + "success": true, + "complete": true, + "sequencerMetrics": { "gasPerSecond": 42000000, "forkChoiceUpdated": 0.001, "getPayload": 0.02, "sendTxs": 0.01 }, + "validatorMetrics": { "gasPerSecond": 420000000, "newPayload": 0.03 } + } + }, + { + "id": "test-synthetic-2", + "sourceFile": "synthetic.yml", + "testName": "Synthetic incomplete run", + "testDescription": "Invented values for tests.", + "outputDir": "synthetic-2", + "createdAt": "2025-12-20T12:00:00Z", + "testConfig": { "BenchmarkRun": "test-synthetic-2" }, + "result": null + } + ] + }, + "loadTests": [ + { + "network": "sepolia", + "timestamp": "2026-01-08T00-00-01", + "result": { + "block_latency": { "min": { "secs": 1, "nanos": 0 }, "max": { "secs": 3, "nanos": 0 }, "mean": { "secs": 2, "nanos": 0 }, "p50": { "secs": 2, "nanos": 0 }, "p95": { "secs": 2, "nanos": 500000000 }, "p99": { "secs": 3, "nanos": 0 } }, + "flashblocks_latency": { "count": 10, "min": { "secs": 0, "nanos": 100000000 }, "max": { "secs": 0, "nanos": 900000000 }, "mean": { "secs": 0, "nanos": 300000000 }, "p50": { "secs": 0, "nanos": 250000000 }, "p90": { "secs": 0, "nanos": 600000000 }, "p95": { "secs": 0, "nanos": 700000000 }, "p99": { "secs": 0, "nanos": 800000000 } }, + "throughput": { "total_submitted": 1000, "total_confirmed": 990, "total_failed": 10, "total_reverted": 0, "tps": 33, "gps": 12500000, "duration": { "secs": 30, "nanos": 0 } }, + "throughput_percentiles": { "tps_p50": 30, "tps_p90": 35, "tps_p99": 36, "tps_max": 36, "gps_p50": 12000000, "gps_p90": 13000000, "gps_p99": 13500000, "gps_max": 13500000 }, + "gas": { "total_gas": 375000000, "avg_gas": 378787, "total_cost_wei": 1000000000000, "avg_gas_price": 1000 }, + "top_failure_reasons": [], + "config": { + "funding_amount": "1000000000000000000", "sender_count": 10, "sender_offset": 0, "in_flight_per_sender": 4, + "duration": "30s", "target_gps": 15000000, "seed": 1, "chain_id": null, + "transactions": [{ "type": "transfer", "weight": 3 }, { "type": "swap", "weight": 1 }], + "looper_contract": null, "swap_token_amount": "0" + } + } + }, + { + "network": "sepolia", + "timestamp": "2026-01-01-00-00-02", + "result": { + "block_latency": { "min": { "secs": 1, "nanos": 0 }, "max": { "secs": 2, "nanos": 0 }, "mean": { "secs": 1, "nanos": 500000000 }, "p50": { "secs": 1, "nanos": 500000000 }, "p95": { "secs": 1, "nanos": 900000000 }, "p99": { "secs": 2, "nanos": 0 } }, + "flashblocks_latency": { "count": 5, "min": { "secs": 0, "nanos": 100000000 }, "max": { "secs": 0, "nanos": 500000000 }, "mean": { "secs": 0, "nanos": 200000000 }, "p50": { "secs": 0, "nanos": 200000000 }, "p90": { "secs": 0, "nanos": 400000000 }, "p95": { "secs": 0, "nanos": 450000000 }, "p99": { "secs": 0, "nanos": 500000000 } }, + "throughput": { "total_submitted": 500, "total_confirmed": 500, "total_failed": 0, "total_reverted": 0, "tps": 17, "gps": 6000000, "duration": { "secs": 30, "nanos": 0 } }, + "throughput_percentiles": { "tps_p50": 16, "tps_p90": 18, "tps_p99": 18, "tps_max": 18, "gps_p50": 6000000, "gps_p90": 6500000, "gps_p99": 6500000, "gps_max": 6500000 }, + "gas": { "total_gas": 180000000, "avg_gas": 360000, "total_cost_wei": 1000000000000, "avg_gas_price": 1000 }, + "top_failure_reasons": [] + } + }, + { + "network": "sepolia", + "timestamp": "2025-12-31T00-00-03", + "result": { "error": "synthetic fatal error" } + } + ] +} diff --git a/app/benchmark/services/dataService.ts b/app/scaling/runs/lib/dataService.ts similarity index 99% rename from app/benchmark/services/dataService.ts rename to app/scaling/runs/lib/dataService.ts index 5f3f51ca..605bb0e8 100644 --- a/app/benchmark/services/dataService.ts +++ b/app/scaling/runs/lib/dataService.ts @@ -5,7 +5,7 @@ import { LoadTestEntry, LoadTestResult, MetricData, -} from "../types"; +} from "./types"; // Load-test endpoints live under the versioned API prefix, unlike benchmark // data which uses the legacy unversioned `output/` paths. Centralized here so diff --git a/app/benchmark/utils/formatters.test.ts b/app/scaling/runs/lib/formatters.test.ts similarity index 100% rename from app/benchmark/utils/formatters.test.ts rename to app/scaling/runs/lib/formatters.test.ts diff --git a/app/benchmark/utils/formatters.tsx b/app/scaling/runs/lib/formatters.tsx similarity index 87% rename from app/benchmark/utils/formatters.tsx rename to app/scaling/runs/lib/formatters.tsx index 0770c6d3..73b8f4a1 100644 --- a/app/benchmark/utils/formatters.tsx +++ b/app/scaling/runs/lib/formatters.tsx @@ -1,4 +1,4 @@ -import { ChartConfig, RustDuration } from "../types"; +import { ChartConfig, LoadTestConfig, RustDuration } from "./types"; const PREFIXES = { "": 1, @@ -227,3 +227,27 @@ export const camelToTitleCase = (str: string) => { .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); }; + +const percent = (value: number): string => `${Math.round(value * 100)}%`; + +export const formatTransactions = ( + config: Pick, +): string => { + const txs = config.transactions; + 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(" · "); + + const freshRecipientRatio = config.fresh_recipient_ratio ?? 0; + return txs + .map((t) => { + if (t.type === "transfer" && freshRecipientRatio >= 1) { + return `account-create (${percent(t.weight / total)})`; + } + if (t.type === "transfer" && freshRecipientRatio > 0) { + return `${t.type} (${percent(t.weight / total)}, ${percent(freshRecipientRatio)} account-create)`; + } + return `${t.type} (${percent(t.weight / total)})`; + }) + .join(" · "); +}; diff --git a/app/benchmark/metricDefinitions.ts b/app/scaling/runs/lib/metricDefinitions.ts similarity index 100% rename from app/benchmark/metricDefinitions.ts rename to app/scaling/runs/lib/metricDefinitions.ts diff --git a/app/benchmark/types.ts b/app/scaling/runs/lib/types.ts similarity index 100% rename from app/benchmark/types.ts rename to app/scaling/runs/lib/types.ts diff --git a/app/scaling/runs/lib/unified.test.ts b/app/scaling/runs/lib/unified.test.ts new file mode 100644 index 00000000..04b4d202 --- /dev/null +++ b/app/scaling/runs/lib/unified.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from 'vitest'; + +import fixture from '../fixtures/synthetic.json'; +import { getTestRunsWithStatus, type BenchmarkRuns } from './types'; +import { toUnifiedRun, weekOf, type LoadRun } from './unified'; + +const [bench, incomplete] = getTestRunsWithStatus(fixture.metadata as BenchmarkRuns).map(toUnifiedRun); +const [load, legacy, fatal] = (fixture.loadTests as unknown as LoadRun[]).map(toUnifiedRun); + +it('maps a bench run without inventing its duration', () => { + expect(bench).toEqual({ + kind: 'bench', + href: '/scaling/runs/bench/test-synthetic-1', + env: 'bench', + network: null, + timestamp: Date.parse('2026-01-07T12:00:00Z'), + commit: null, + machine: 'synthetic.large', + state: 'unknown', + duration: null, + profile: null, + workload: 'synthetic-payload', + gasPerSecond: 42e6, + latency: [{ label: 'get-payload', seconds: 0.02 }, { label: 'new-payload', seconds: 0.03 }], + status: 'success', + areas: ['execution'], + qualifies: { ok: false, reason: 'duration not recorded; state size not recorded' }, + }); + expect(incomplete).toMatchObject({ status: 'incomplete', machine: null, gasPerSecond: null, latency: [], workload: 'Synthetic incomplete run' }); +}); + +it('maps a load test as a burst with its mix, observed gas/s and p99 latencies', () => { + expect(load).toMatchObject({ + kind: 'load', + href: '/scaling/runs/load/sepolia/2026-01-08T00-00-01', + env: 'sepolia', + timestamp: Date.UTC(2026, 0, 8, 0, 0, 1), + duration: 30, + profile: 'burst', + workload: 'transfer (75%) · swap (25%) · target 15 Mgas/s', + gasPerSecond: 12.5e6, + latency: [{ label: 'block p99', seconds: 3 }, { label: 'flashblocks p99', seconds: 0.8 }], + status: 'success', + areas: ['execution', 'da', 'networking'], + qualifies: { ok: false, reason: '30 s burst, needs ≥ 1 h; state size not recorded' }, + }); + expect(legacy).toMatchObject({ timestamp: Date.UTC(2026, 0, 1, 0, 0, 2), workload: '—', profile: 'burst' }); + expect(fatal).toMatchObject({ status: 'fatal', duration: null, profile: null, gasPerSecond: null, latency: [] }); +}); + +it('leaves status unknown when a load result could not be fetched', () => { + expect(toUnifiedRun({ network: 'sepolia', timestamp: '2026-01-08T00-00-01' })).toMatchObject({ status: null, duration: null }); +}); + +it('groups by the Monday that starts the UTC week', () => { + expect(weekOf(Date.UTC(2026, 0, 11, 23, 59))).toBe('2026-01-05'); + expect(weekOf(Date.UTC(2026, 0, 12))).toBe('2026-01-12'); +}); diff --git a/app/scaling/runs/lib/unified.ts b/app/scaling/runs/lib/unified.ts new file mode 100644 index 00000000..5d71fea6 --- /dev/null +++ b/app/scaling/runs/lib/unified.ts @@ -0,0 +1,83 @@ +import { durationToMs, formatTransactions, parseLoadTestTimestamp } from './formatters'; +import type { BenchmarkRunWithStatus, LoadTestEntry, LoadTestResult, RunStatus, RustDuration } from './types'; + +export type LoadRun = LoadTestEntry & { result?: LoadTestResult }; + +export type UnifiedRun = { + kind: 'bench' | 'load'; + href: string; + env: string; + network: string | null; + timestamp: number; + commit: string | null; + machine: string | null; + state: 'unknown'; + duration: number | null; + profile: 'sustained' | 'burst' | null; + workload: string; + gasPerSecond: number | null; + latency: { label: string; seconds: number }[]; + status: RunStatus | null; + areas: ('execution' | 'da' | 'networking')[]; + qualifies: { ok: false; reason: string }; +}; + +const SUSTAINED_SECONDS = 3600; +const secs = (d: RustDuration) => durationToMs(d) / 1000; + +export function toUnifiedRun(run: BenchmarkRunWithStatus | LoadRun): UnifiedRun { + return 'testConfig' in run ? fromBench(run) : fromLoad(run); +} + +function fromBench(run: BenchmarkRunWithStatus): UnifiedRun { + const r = run.result; + // metadata.json records no block count, so a bench run's duration is unknown. + return withQualifies({ + kind: 'bench', + href: `/scaling/runs/bench/${run.id}`, + env: 'bench', + network: null, + timestamp: Date.parse(run.createdAt), + commit: r?.clientVersion ?? null, + machine: [run.machineInfo?.type, run.machineInfo?.provider, run.machineInfo?.region].filter(Boolean).join(' · ') || null, + duration: null, + workload: String(run.testConfig.TransactionPayload ?? run.testName), + gasPerSecond: r?.sequencerMetrics?.gasPerSecond ?? null, + latency: [r?.sequencerMetrics && { label: 'get-payload', seconds: r.sequencerMetrics.getPayload }, r?.validatorMetrics && { label: 'new-payload', seconds: r.validatorMetrics.newPayload }].filter((l) => !!l), + status: run.status, + areas: ['execution'], + }); +} + +function fromLoad({ network, timestamp, result }: LoadRun): UnifiedRun { + const config = result?.config; + const target = config ? `target ${config.target_gps / 1e6} Mgas/s` : undefined; + return withQualifies({ + kind: 'load', + href: `/scaling/runs/load/${network}/${timestamp}`, + env: network, + network, + timestamp: parseLoadTestTimestamp(timestamp)?.getTime() ?? 0, + commit: null, + machine: null, + duration: result?.throughput ? secs(result.throughput.duration) : null, + workload: [config && formatTransactions(config), target].filter(Boolean).join(' · ') || '—', + gasPerSecond: result?.throughput?.gps ?? null, + latency: result?.block_latency ? [{ label: 'block p99', seconds: secs(result.block_latency.p99) }, { label: 'flashblocks p99', seconds: secs(result.flashblocks_latency.p99) }] : [], + status: result ? (result.error ? 'fatal' : 'success') : null, + areas: ['execution', 'da', 'networking'], + }); +} + +function withQualifies(run: Omit): UnifiedRun { + const { duration } = run; + const profile = duration === null ? null : duration >= SUSTAINED_SECONDS ? 'sustained' : 'burst'; + const reasons = [duration === null ? 'duration not recorded' : profile === 'burst' ? `${duration} s burst, needs ≥ 1 h` : null, 'state size not recorded']; + return { ...run, state: 'unknown', profile, qualifies: { ok: false, reason: reasons.filter(Boolean).join('; ') } }; +} + +export function weekOf(ts: number): string { + const d = new Date(ts); + d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7)); + return d.toISOString().slice(0, 10); +} diff --git a/app/scaling/runs/page.test.tsx b/app/scaling/runs/page.test.tsx new file mode 100644 index 00000000..60d4985e --- /dev/null +++ b/app/scaling/runs/page.test.tsx @@ -0,0 +1,34 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, it, vi } from 'vitest'; + +import fixture from './fixtures/synthetic.json'; +import { getTestRunsWithStatus, type BenchmarkRuns } from './lib/types'; +import { toUnifiedRun, type LoadRun } from './lib/unified'; +import RunsPage from './page'; + +const swr = vi.hoisted(() => ({ value: {} as { data?: unknown; error?: unknown } })); +vi.mock('swr', () => ({ default: () => swr.value })); + +const runs = [ + ...getTestRunsWithStatus(fixture.metadata as BenchmarkRuns).map(toUnifiedRun), + ...(fixture.loadTests as unknown as LoadRun[]).map(toUnifiedRun), +].sort((a, b) => b.timestamp - a.timestamp); + +it('lists both families newest first, grouped by week, linking to their detail pages', () => { + swr.value = { data: { runs, loadTotal: 3 } }; + const html = renderToStaticMarkup(); + const order = ['Week of 2026-01-05', '/scaling/runs/load/sepolia/2026-01-08T00-00-01', '/scaling/runs/bench/test-synthetic-1', + 'Week of 2025-12-29', '/scaling/runs/load/sepolia/2026-01-01-00-00-02', '/scaling/runs/load/sepolia/2025-12-31T00-00-03', + 'Week of 2025-12-15', '/scaling/runs/bench/test-synthetic-2']; + const at = order.map((s) => html.indexOf(s)); + expect(at.every((i) => i >= 0)).toBe(true); + expect(at).toEqual([...at].sort((a, b) => a - b)); + expect(html).toContain('12.5'); + expect(html).toContain('flashblocks p99 800.0 ms'); + expect(html).toContain('the latest 3 of 3 load tests'); +}); + +it('shows the data-source error', () => { + swr.value = { error: new Error('Benchmark data source is not configured.') }; + expect(renderToStaticMarkup()).toContain('Benchmark data source is not configured.'); +}); diff --git a/app/scaling/runs/page.tsx b/app/scaling/runs/page.tsx new file mode 100644 index 00000000..d406a48b --- /dev/null +++ b/app/scaling/runs/page.tsx @@ -0,0 +1,111 @@ +'use client'; + +import Link from 'next/link'; +import { useState, type ReactNode } from 'react'; +import useSWR from 'swr'; + +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 { Tag } from '../components/Tag'; +import { getDataService } from './lib/dataService'; +import { formatValue } from './lib/formatters'; +import { getTestRunsWithStatus, type RunStatus } from './lib/types'; +import { toUnifiedRun, weekOf, type UnifiedRun } from './lib/unified'; + +// report-api has no endpoint listing load-test networks. +const NETWORK = 'sepolia'; +// One request per load-test result; nightly runs grow without bound. +const LOAD_LIMIT = 30; + +const TH = 'px-3 py-2 text-left text-[12px] font-[500] text-bds-gray-60'; +const TD = 'px-3 py-2 align-top'; +const DOT: Record = { success: 'bg-bds-green-50', warning: 'bg-bds-yellow-50', error: 'bg-bds-red-50', fatal: 'bg-bds-red-50', incomplete: 'bg-bds-gray-30' }; + +async function loadRuns() { + const api = getDataService(); + const [metadata, entries] = await Promise.all([api.getMetadata(), api.getLoadTestList(NETWORK)]); + const recent = entries.slice(0, LOAD_LIMIT); + // A missing result leaves that row's metrics unknown instead of failing the whole index. + const results = await Promise.all(recent.map((e) => api.getLoadTestResult(e.network, e.timestamp).catch(() => undefined))); + const runs = [...getTestRunsWithStatus(metadata), ...recent.map((e, i) => ({ ...e, result: results[i] }))].map(toUnifiedRun); + runs.sort((a, b) => b.timestamp - a.timestamp); + return { runs, loadTotal: entries.length }; +} + +export default function RunsPage() { + const { data, error } = useSWR('scaling-runs', loadRuns); + const [kind, setKind] = useState(null); + const [env, setEnv] = useState(null); + const [qualifying, setQualifying] = useState(false); + + const runs = (data?.runs ?? []).filter((r) => (!kind || r.kind === kind) && (!env || r.env === env) && (!qualifying || r.qualifies.ok)); + const weeks = Map.groupBy(runs, (r) => weekOf(r.timestamp)); + const envs = [...new Set(data?.runs.map((r) => r.env))]; + + return ( +
+
+ Scaling / Runs + Runs + Base-bench runs and {data ? `the latest ${Math.min(LOAD_LIMIT, data.loadTotal)} of ${data.loadTotal}` : 'the latest'} load tests, newest first. +
+
+ {([null, 'bench', 'load'] as const).map((k) => setKind(k)}>{k ?? 'all kinds'})} + {[null, ...envs].map((e) => setEnv(e)}>{e ?? 'all envs'})} + setQualifying(!qualifying)}>qualifying only +
+ {error ? ( + + ) : !data ? ( + Loading runs… + ) : runs.length === 0 ? ( + + ) : ( + + + + + {['When', 'Kind', 'Env', 'Profile', 'Workload', 'Mgas/s', 'Latency', 'Status', 'Duration', 'Machine'].map((h) => )} + + + {[...weeks].map(([week, rows]) => ( + + + {rows.map((r) => )} + + ))} +
{h}
Week of {week}
+
+ )} +
+ ); +} + +function Row({ run: r }: { run: UnifiedRun }) { + return ( + + {new Date(r.timestamp).toISOString().slice(0, 16).replace('T', ' ')} + + {r.env} + {r.profile ? : '—'}{r.qualifies.reason} + {r.workload} + {r.gasPerSecond === null ? '—' : (r.gasPerSecond / 1e6).toFixed(1)} + + {r.latency.length ? r.latency.map((l) =>
{l.label} {formatValue(l.seconds, 's')}
) : '—'} + + + + + {r.duration === null ? '—' : formatValue(r.duration, 's')} + {r.machine ?? '—'} + + ); +} + +function Chip({ on, onClick, children }: { on: boolean; onClick: () => void; children: ReactNode }) { + const tone = on ? 'border-base-blue text-base-blue' : 'border-bds-gray-20 text-bds-gray-60'; + return ; +}