Skip to content

Commit 14f6c26

Browse files
authored
feat(ui): gas-bucket filter on comparison page (#208)
## Summary Adds gas-used bucket filtering to the comparison page, matching the "Group by Gas used" feature on the run detail heatmap. - **Multi-select gas-bucket chips** in the main controls row (30M steps: All | 0M | 30M | 60M | 90M | ...). Selection persisted in URL via \`?gasBuckets=30,60\`. - **Sticky scroll bar**: text filter + gas chips together on the second row so they stay accessible while scrolling through charts. - **Filter-aware metric cards**: Tests, MGas/s, Total Gas, Duration, etc. now aggregate only from tests matching the active filters (both text/regex and gas buckets). - **Gas column in the comparison table**: sortable column after the test name showing per-test \`gas_used_total\` in \`XM\` format. - Composes with the existing text/regex filter — both must match for a test to appear. ## Test plan - [x] \`npx tsc --noEmit\` clean - [x] \`npx eslint\` clean - [x] Manual: open comparison page with 2+ runs; confirm gas chips appear; toggle buckets and verify charts + table + metric cards all update - [x] Manual: combine a text filter with a gas-bucket selection; confirm both constraints apply - [x] Manual: scroll down; confirm sticky bar shows both filter + gas chips on the second row - [x] Manual: sort the table by the Gas column; confirm ordering is correct
1 parent 41759ef commit 14f6c26

4 files changed

Lines changed: 213 additions & 29 deletions

File tree

ui/src/components/compare/MetricsComparison.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface MetricsComparisonProps {
1212
baselineIdx: number
1313
onBaselineChange: (idx: number) => void
1414
labelMode: LabelMode
15+
testNameFilter?: (name: string) => boolean
1516
}
1617

1718
interface ComputedMetrics {
@@ -26,14 +27,22 @@ interface ComputedMetrics {
2627
totalRuntime: number | undefined
2728
}
2829

29-
function computeMetrics(config: RunConfig, result: RunResult | null, stepFilter: StepTypeOption[]): ComputedMetrics {
30-
const aggregatedStats = result
31-
? Object.values(result.tests).map((t) => getAggregatedStats(t, stepFilter)).filter((s): s is AggregatedStats => s !== undefined)
30+
function computeMetrics(config: RunConfig, result: RunResult | null, stepFilter: StepTypeOption[], testNameFilter?: (name: string) => boolean): ComputedMetrics {
31+
const filteredTests = result
32+
? Object.entries(result.tests).filter(([name]) => !testNameFilter || testNameFilter(name))
3233
: []
3334

34-
const testCount = config.test_counts?.total ?? (result ? Object.keys(result.tests).length : 0)
35-
const passedTests = config.test_counts?.passed ?? aggregatedStats.filter((s) => s.fail === 0).length
36-
const failedTests = config.test_counts ? (config.test_counts.total - config.test_counts.passed) : aggregatedStats.filter((s) => s.fail > 0).length
35+
const aggregatedStats = filteredTests
36+
.map(([, t]) => getAggregatedStats(t, stepFilter))
37+
.filter((s): s is AggregatedStats => s !== undefined)
38+
39+
const testCount = testNameFilter
40+
? filteredTests.length
41+
: (config.test_counts?.total ?? (result ? Object.keys(result.tests).length : 0))
42+
const passedTests = testNameFilter
43+
? aggregatedStats.filter((s) => s.fail === 0).length
44+
: (config.test_counts?.passed ?? aggregatedStats.filter((s) => s.fail === 0).length)
45+
const failedTests = testCount - passedTests
3746
const totalDuration = aggregatedStats.reduce((sum, s) => sum + s.time_total, 0)
3847
const totalGasUsed = aggregatedStats.reduce((sum, s) => sum + s.gas_used_total, 0)
3948
const totalGasUsedTime = aggregatedStats.reduce((sum, s) => sum + s.gas_used_time_total, 0)
@@ -149,8 +158,8 @@ function MetricCard({
149158
)
150159
}
151160

152-
export function MetricsComparison({ runs, stepFilter, baselineIdx, onBaselineChange, labelMode }: MetricsComparisonProps) {
153-
const metrics = runs.map((r) => computeMetrics(r.config, r.result, stepFilter))
161+
export function MetricsComparison({ runs, stepFilter, baselineIdx, onBaselineChange, labelMode, testNameFilter }: MetricsComparisonProps) {
162+
const metrics = runs.map((r) => computeMetrics(r.config, r.result, stepFilter, testNameFilter))
154163
const clients = runs.map((r) => r.config.instance.client)
155164
const runLabels = runs.map((r) => formatRunLabel(RUN_SLOTS[r.index], r, labelMode))
156165
const base = metrics[baselineIdx]

ui/src/components/compare/StickyRunBar.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ interface StickyRunBarProps {
1313
testFilterRegex: boolean
1414
onTestFilterChange: (query: string) => void
1515
onTestFilterRegexChange: (enabled: boolean) => void
16+
/** Gas-bucket filter state for the second row */
17+
availableGasBuckets: number[]
18+
selectedGasBuckets: Set<number>
19+
onToggleGasBucket: (bucket: number) => void
20+
onClearGasBuckets: () => void
1621
}
1722

18-
export function StickyRunBar({ runs, sentinelRef, labelMode, onLabelModeChange, testFilter, testFilterRegex, onTestFilterChange, onTestFilterRegexChange }: StickyRunBarProps) {
23+
export function StickyRunBar({ runs, sentinelRef, labelMode, onLabelModeChange, testFilter, testFilterRegex, onTestFilterChange, onTestFilterRegexChange, availableGasBuckets, selectedGasBuckets, onToggleGasBucket, onClearGasBuckets }: StickyRunBarProps) {
1924
const [visible, setVisible] = useState(false)
2025
const observerRef = useRef<IntersectionObserver | null>(null)
2126

@@ -36,7 +41,8 @@ export function StickyRunBar({ runs, sentinelRef, labelMode, onLabelModeChange,
3641

3742
return (
3843
<div className="fixed top-0 right-0 left-0 z-50 border-b border-gray-200 bg-white/95 backdrop-blur-sm dark:border-gray-700 dark:bg-gray-900/95">
39-
<div className="mx-auto flex max-w-7xl items-center justify-center gap-4 px-4 py-2">
44+
<div className="mx-auto flex max-w-7xl flex-col gap-1 px-4 py-2">
45+
<div className="flex items-center justify-center gap-4">
4046
{runs.map((run) => {
4147
const slot = RUN_SLOTS[run.index]
4248
return (
@@ -64,7 +70,10 @@ export function StickyRunBar({ runs, sentinelRef, labelMode, onLabelModeChange,
6470
))}
6571
</div>
6672
</div>
67-
<div className="flex items-center gap-1.5 text-xs/5 text-gray-500 dark:text-gray-400">
73+
</div>
74+
{/* Filter + gas bucket row */}
75+
<div className="flex items-center justify-center gap-4 text-xs/5 text-gray-500 dark:text-gray-400">
76+
<div className="flex items-center gap-1.5">
6877
<span>Filter:</span>
6978
<FilterInput
7079
placeholder={testFilterRegex ? 'Regex...' : 'Filter...'}
@@ -90,6 +99,37 @@ export function StickyRunBar({ runs, sentinelRef, labelMode, onLabelModeChange,
9099
.*
91100
</button>
92101
</div>
102+
{availableGasBuckets.length > 1 && (
103+
<div className="flex items-center gap-1.5">
104+
<span>Gas:</span>
105+
<div className="flex flex-wrap gap-1">
106+
<button
107+
onClick={onClearGasBuckets}
108+
className={`rounded-xs px-2 py-0.5 text-xs/5 font-medium transition-colors ${
109+
selectedGasBuckets.size === 0
110+
? 'bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900'
111+
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
112+
}`}
113+
>
114+
All
115+
</button>
116+
{availableGasBuckets.map((bucket) => (
117+
<button
118+
key={bucket}
119+
onClick={() => onToggleGasBucket(bucket)}
120+
className={`rounded-xs px-2 py-0.5 text-xs/5 font-medium transition-colors ${
121+
selectedGasBuckets.has(bucket)
122+
? 'bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900'
123+
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
124+
}`}
125+
>
126+
{Math.round(bucket / 1_000_000)}M
127+
</button>
128+
))}
129+
</div>
130+
</div>
131+
)}
132+
</div>
93133
</div>
94134
</div>
95135
)

ui/src/components/compare/TestComparisonTable.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ interface TestComparisonTableProps {
2121
testNameFilter?: (name: string) => boolean
2222
}
2323

24-
type SortColumn = 'order' | 'name' | 'avgValue' | `run-${number}`
24+
type SortColumn = 'order' | 'name' | 'gasUsed' | 'avgValue' | `run-${number}`
2525
type SortDirection = 'asc' | 'desc'
2626
type TableBaseline = 'best' | 'worst' | number
2727

2828
interface ComparedTest {
2929
name: string
3030
order: number
31+
gasUsed: number | undefined
3132
values: (number | undefined)[]
3233
avgValue: number | undefined
3334
}
@@ -198,7 +199,22 @@ export function TestComparisonTable({ runs, suiteTests, stepFilter, blockLogsPer
198199
const defined = values.filter((v): v is number => v !== undefined)
199200
const avgValue = defined.length > 0 ? defined.reduce((a, b) => a + b, 0) / defined.length : undefined
200201

201-
tests.push({ name, order, values, avgValue })
202+
// Gas used from the first run that has this test (tests are the
203+
// same across compared runs in normal usage). Used for the Gas
204+
// column in the table.
205+
let gasUsed: number | undefined
206+
for (const run of runs) {
207+
const entry = run.result?.tests[name]
208+
if (entry) {
209+
const stats = getAggregatedStats(entry, stepFilter)
210+
if (stats) {
211+
gasUsed = stats.gas_used_total
212+
break
213+
}
214+
}
215+
}
216+
217+
tests.push({ name, order, gasUsed, values, avgValue })
202218
}
203219
return tests
204220
}, [runs, suiteOrder, stepFilter, activeTab, blockLogsPerRun])
@@ -234,6 +250,9 @@ export function TestComparisonTable({ runs, suiteTests, stepFilter, blockLogsPer
234250
case 'name':
235251
cmp = a.name.localeCompare(b.name)
236252
break
253+
case 'gasUsed':
254+
cmp = (a.gasUsed ?? 0) - (b.gasUsed ?? 0)
255+
break
237256
case 'avgValue':
238257
cmp = (a.avgValue ?? 0) - (b.avgValue ?? 0)
239258
break
@@ -329,6 +348,7 @@ export function TestComparisonTable({ runs, suiteTests, stepFilter, blockLogsPer
329348
<tr>
330349
<SortableHeader label="#" column="order" currentSort={sortBy} currentDirection={sortDir} onSort={handleSort} className="w-12 px-3 py-3" />
331350
<SortableHeader label="Test Name" column="name" currentSort={sortBy} currentDirection={sortDir} onSort={handleSort} />
351+
<SortableHeader label="Gas" column="gasUsed" currentSort={sortBy} currentDirection={sortDir} onSort={handleSort} className="px-4 py-3 text-right" />
332352
<SortableHeader label="Avg" column="avgValue" currentSort={sortBy} currentDirection={sortDir} onSort={handleSort} className="px-4 py-3 text-right" />
333353
{runs.map((run, i) => {
334354
const slot = RUN_SLOTS[run.index]
@@ -378,6 +398,9 @@ export function TestComparisonTable({ runs, suiteTests, stepFilter, blockLogsPer
378398
<td className="max-w-sm truncate px-4 py-2 text-sm/6 text-gray-900 dark:text-gray-100" title={test.name}>
379399
{test.name}
380400
</td>
401+
<td className="whitespace-nowrap px-4 py-2 text-right text-xs/5 text-gray-500 dark:text-gray-400">
402+
{test.gasUsed !== undefined ? `${Math.round(test.gasUsed / 1_000_000)}M` : '-'}
403+
</td>
381404
<td className="whitespace-nowrap px-4 py-2 text-right text-sm/6 text-gray-400 dark:text-gray-500">
382405
{test.avgValue !== undefined ? activeMetric.format(test.avgValue) : '-'}
383406
</td>

ui/src/pages/ComparePage.tsx

Lines changed: 128 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export function ComparePage() {
4444
diffFilter?: string
4545
filter?: string
4646
filterRegex?: string
47+
gasBuckets?: string
4748
}
4849

4950
// Backward-compat redirect: ?a=X&b=Y → ?runs=X,Y
@@ -120,7 +121,7 @@ export function ComparePage() {
120121
? Math.min(parseInt(search.tableBase, 10) || 0, runIds.length - 1)
121122
: 'best'
122123

123-
const tableSortBy = (search.sort ?? 'order') as 'order' | 'name' | 'avgValue' | `run-${number}`
124+
const tableSortBy = (search.sort ?? 'order') as 'order' | 'name' | 'gasUsed' | 'avgValue' | `run-${number}`
124125
const tableSortDir = (search.sortDir === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc'
125126
const diffFilter = (search.diffFilter === 'faster' || search.diffFilter === 'slower' ? search.diffFilter : 'all') as 'all' | 'faster' | 'slower'
126127
const testFilter = search.filter ?? ''
@@ -129,27 +130,83 @@ export function ComparePage() {
129130
const [chartZoom, setChartZoom] = useState({ start: 0, end: 100 })
130131
const [chartType, setChartType] = useState<ChartType>('line')
131132

132-
const testNameFilter = useMemo(() => {
133-
if (!testFilter) return undefined
134-
if (testFilterRegex) {
135-
try {
136-
const re = new RegExp(testFilter, 'i')
137-
return (name: string) => re.test(name)
138-
} catch {
139-
return undefined
133+
// ─── Gas bucket filter ─────────────────────────────────────────
134+
// Parse the currently-selected gas buckets from the URL. Empty set
135+
// means "show all" (no filtering). Values are in millions (e.g.
136+
// "30,60" means the 30M and 60M buckets).
137+
const GAS_BUCKET_STEP = 30_000_000 // 30M gas per bucket
138+
139+
const selectedGasBuckets = useMemo(() => {
140+
if (!search.gasBuckets) return new Set<number>()
141+
return new Set(
142+
search.gasBuckets.split(',').map((s) => parseInt(s, 10) * 1_000_000).filter((n) => !isNaN(n)),
143+
)
144+
}, [search.gasBuckets])
145+
146+
// Compute per-test gas used from the first run that has results.
147+
// Used both for the available-buckets list and for the filter.
148+
const testGasMap = useMemo(() => {
149+
const map = new Map<string, number>()
150+
// Use the first result that's loaded — tests are typically the
151+
// same across compared runs (same suite).
152+
const result = resultQueries.find((q) => q.data)?.data
153+
if (!result) return map
154+
for (const [name, entry] of Object.entries(result.tests)) {
155+
const step = entry.steps?.test
156+
if (step) {
157+
map.set(name, step.aggregated.gas_used_total)
140158
}
141159
}
142-
const q = testFilter.toLowerCase()
143-
return (name: string) => name.toLowerCase().includes(q)
144-
}, [testFilter, testFilterRegex])
160+
return map
161+
}, [resultQueries])
162+
163+
// Available gas buckets sorted ascending. Shown as chips.
164+
const availableGasBuckets = useMemo(() => {
165+
const buckets = new Set<number>()
166+
for (const gas of testGasMap.values()) {
167+
buckets.add(Math.round(gas / GAS_BUCKET_STEP) * GAS_BUCKET_STEP)
168+
}
169+
return [...buckets].sort((a, b) => a - b)
170+
}, [testGasMap, GAS_BUCKET_STEP])
171+
172+
const testNameFilter = useMemo(() => {
173+
// Combine text/regex filter with gas-bucket filter.
174+
const textFn = (() => {
175+
if (!testFilter) return undefined
176+
if (testFilterRegex) {
177+
try {
178+
const re = new RegExp(testFilter, 'i')
179+
return (name: string) => re.test(name)
180+
} catch {
181+
return undefined
182+
}
183+
}
184+
const q = testFilter.toLowerCase()
185+
return (name: string) => name.toLowerCase().includes(q)
186+
})()
187+
188+
const gasFn = selectedGasBuckets.size > 0
189+
? (name: string) => {
190+
const gas = testGasMap.get(name)
191+
if (gas === undefined) return false
192+
const bucket = Math.round(gas / GAS_BUCKET_STEP) * GAS_BUCKET_STEP
193+
return selectedGasBuckets.has(bucket)
194+
}
195+
: undefined
196+
197+
if (!textFn && !gasFn) return undefined
198+
if (textFn && !gasFn) return textFn
199+
if (!textFn && gasFn) return gasFn
200+
return (name: string) => textFn!(name) && gasFn!(name)
201+
}, [testFilter, testFilterRegex, selectedGasBuckets, testGasMap, GAS_BUCKET_STEP])
145202

146203
const updateSearch = useCallback((patch: Record<string, string | undefined>) => {
147204
navigate({
148205
to: '/compare',
149-
search: { runs: search.runs, steps: search.steps, baseline: search.baseline, labels: search.labels, tableBase: search.tableBase, sort: search.sort, sortDir: search.sortDir, diffFilter: search.diffFilter, filter: search.filter, filterRegex: search.filterRegex, ...patch },
206+
search: { runs: search.runs, steps: search.steps, baseline: search.baseline, labels: search.labels, tableBase: search.tableBase, sort: search.sort, sortDir: search.sortDir, diffFilter: search.diffFilter, filter: search.filter, filterRegex: search.filterRegex, gasBuckets: search.gasBuckets, ...patch },
150207
replace: true,
151208
})
152-
}, [navigate, search.runs, search.steps, search.baseline, search.labels, search.tableBase, search.sort, search.sortDir, search.diffFilter, search.filter, search.filterRegex])
209+
}, [navigate, search.runs, search.steps, search.baseline, search.labels, search.tableBase, search.sort, search.sortDir, search.diffFilter, search.filter, search.filterRegex, search.gasBuckets])
153210

154211
const setBaselineIdx = useCallback((idx: number) => {
155212
updateSearch({ baseline: idx > 0 ? String(idx) : undefined })
@@ -173,6 +230,31 @@ export function ComparePage() {
173230
updateSearch({ filterRegex: enabled ? '1' : undefined })
174231
}, [updateSearch])
175232

233+
const setGasBuckets = useCallback(
234+
(buckets: Set<number>) => {
235+
if (buckets.size === 0) {
236+
updateSearch({ gasBuckets: undefined })
237+
} else {
238+
const sorted = [...buckets].sort((a, b) => a - b)
239+
updateSearch({ gasBuckets: sorted.map((v) => String(v / 1_000_000)).join(',') })
240+
}
241+
},
242+
[updateSearch],
243+
)
244+
245+
const toggleGasBucket = useCallback(
246+
(bucket: number) => {
247+
const next = new Set(selectedGasBuckets)
248+
if (next.has(bucket)) {
249+
next.delete(bucket)
250+
} else {
251+
next.add(bucket)
252+
}
253+
setGasBuckets(next)
254+
},
255+
[selectedGasBuckets, setGasBuckets],
256+
)
257+
176258
// Handle backward-compat redirect in progress
177259
if (search.a && search.b && !search.runs) {
178260
return <LoadingState message="Redirecting..." />
@@ -216,7 +298,7 @@ export function ComparePage() {
216298

217299
return (
218300
<div className="flex flex-col gap-6">
219-
<StickyRunBar runs={runs} sentinelRef={headerRef} labelMode={labelMode} onLabelModeChange={setLabelMode} testFilter={testFilter} testFilterRegex={testFilterRegex} onTestFilterChange={setTestFilter} onTestFilterRegexChange={setTestFilterRegex} />
301+
<StickyRunBar runs={runs} sentinelRef={headerRef} labelMode={labelMode} onLabelModeChange={setLabelMode} testFilter={testFilter} testFilterRegex={testFilterRegex} onTestFilterChange={setTestFilter} onTestFilterRegexChange={setTestFilterRegex} availableGasBuckets={availableGasBuckets} selectedGasBuckets={selectedGasBuckets} onToggleGasBucket={toggleGasBucket} onClearGasBuckets={() => setGasBuckets(new Set())} />
220302

221303
{/* Breadcrumb */}
222304
<div className="flex min-w-0 items-center gap-2 text-sm/6 text-gray-500 dark:text-gray-400">
@@ -316,6 +398,36 @@ export function ComparePage() {
316398
.*
317399
</button>
318400
</div>
401+
{availableGasBuckets.length > 1 && (
402+
<div className="flex items-center gap-1.5">
403+
<span>Gas:</span>
404+
<div className="flex flex-wrap gap-1">
405+
<button
406+
onClick={() => setGasBuckets(new Set())}
407+
className={`rounded-xs px-2 py-0.5 text-xs/5 font-medium transition-colors ${
408+
selectedGasBuckets.size === 0
409+
? 'bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900'
410+
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
411+
}`}
412+
>
413+
All
414+
</button>
415+
{availableGasBuckets.map((bucket) => (
416+
<button
417+
key={bucket}
418+
onClick={() => toggleGasBucket(bucket)}
419+
className={`rounded-xs px-2 py-0.5 text-xs/5 font-medium transition-colors ${
420+
selectedGasBuckets.has(bucket)
421+
? 'bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900'
422+
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
423+
}`}
424+
>
425+
{Math.round(bucket / 1_000_000)}M
426+
</button>
427+
))}
428+
</div>
429+
</div>
430+
)}
319431
</div>
320432

321433
{suiteMismatch && (
@@ -336,7 +448,7 @@ export function ComparePage() {
336448
}} />
337449
</div>
338450

339-
<MetricsComparison runs={runs} stepFilter={stepFilter} baselineIdx={baselineIdx} onBaselineChange={setBaselineIdx} labelMode={labelMode} />
451+
<MetricsComparison runs={runs} stepFilter={stepFilter} baselineIdx={baselineIdx} onBaselineChange={setBaselineIdx} labelMode={labelMode} testNameFilter={testNameFilter} />
340452

341453
{allResults && (
342454
<MGasComparisonChart runs={runs} suiteTests={suite?.tests} stepFilter={stepFilter} labelMode={labelMode} testNameFilter={testNameFilter} zoomRange={sharedZoom ? chartZoom : undefined} onZoomChange={sharedZoom ? setChartZoom : undefined} chartType={chartType} />

0 commit comments

Comments
 (0)