Skip to content

Commit d3fef1f

Browse files
authored
feat(ui): filter heatmap tests (#102)
1 parent 77da867 commit d3fef1f

2 files changed

Lines changed: 156 additions & 25 deletions

File tree

ui/src/components/suite-detail/TestHeatmap.tsx

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useState } from 'react'
1+
import { Fragment, useMemo, useState } from 'react'
22
import { Link } from '@tanstack/react-router'
33
import clsx from 'clsx'
44
import { ChevronUp } from 'lucide-react'
@@ -64,6 +64,36 @@ function formatMGasCompact(mgas: number): string {
6464
return mgas.toFixed(1)
6565
}
6666

67+
function splitByMatch(name: string, search: string, isRegex: boolean): { text: string; highlight: boolean }[] {
68+
if (!search) return [{ text: name, highlight: false }]
69+
try {
70+
const pattern = isRegex ? search : search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
71+
const re = new RegExp(`(${pattern})`, 'gi')
72+
const parts = name.split(re)
73+
if (parts.length === 1) return [{ text: name, highlight: false }]
74+
return parts.filter(Boolean).map((part) => ({ text: part, highlight: re.test(part) }))
75+
} catch {
76+
return [{ text: name, highlight: false }]
77+
}
78+
}
79+
80+
function HighlightedName({ name, search, useRegex }: { name: string; search: string; useRegex: boolean }) {
81+
const parts = splitByMatch(name, search, useRegex)
82+
return (
83+
<>
84+
{parts.map((part, i) =>
85+
part.highlight ? (
86+
<mark key={i} className="rounded-xs bg-yellow-200 text-yellow-900 dark:bg-yellow-700/50 dark:text-yellow-200">
87+
{part.text}
88+
</mark>
89+
) : (
90+
<span key={i}>{part.text}</span>
91+
),
92+
)}
93+
</>
94+
)
95+
}
96+
6797
interface RunData {
6898
runId: string
6999
mgas: number
@@ -101,12 +131,16 @@ interface TestHeatmapProps {
101131
testFiles?: SuiteTest[]
102132
isDark: boolean
103133
stepFilter?: IndexStepType[]
134+
searchQuery?: string
135+
onSearchChange?: (query: string | undefined) => void
136+
showTestName?: boolean
137+
onShowTestNameChange?: (show: boolean) => void
104138
}
105139

106140
type SortDirection = 'asc' | 'desc'
107141
type SortField = 'testNumber' | 'avgMgas'
108142

109-
export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_STEP_TYPES }: TestHeatmapProps) {
143+
export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_STEP_TYPES, searchQuery, onSearchChange, showTestName: showTestNameProp, onShowTestNameChange }: TestHeatmapProps) {
110144
const [tooltip, setTooltip] = useState<TooltipData | null>(null)
111145
const [currentPage, setCurrentPage] = useState(1)
112146
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
@@ -116,6 +150,8 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
116150
const [runsPerClient, setRunsPerClient] = useState(DEFAULT_RUNS_PER_CLIENT)
117151
const [statDisplay, setStatDisplay] = useState<StatDisplayType>('Avg')
118152
const [showClientStat, setShowClientStat] = useState(true)
153+
const showTestName = showTestNameProp ?? false
154+
const [useRegex, setUseRegex] = useState(false)
119155
const [statColumnType, setStatColumnType] = useState<DistributionStatType>('Avg')
120156

121157
const { allTests, clients } = useMemo(() => {
@@ -219,9 +255,26 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
219255
return { allTests: processedTests, clients }
220256
}, [stats, testFiles, runsPerClient, stepFilter])
221257

258+
const search = searchQuery ?? ''
259+
260+
// Filter by search query
261+
const filteredTests = useMemo(() => {
262+
if (!search) return allTests
263+
if (useRegex) {
264+
try {
265+
const re = new RegExp(search, 'i')
266+
return allTests.filter((t) => re.test(t.name))
267+
} catch {
268+
return allTests
269+
}
270+
}
271+
const lower = search.toLowerCase()
272+
return allTests.filter((t) => t.name.toLowerCase().includes(lower))
273+
}, [allTests, search, useRegex])
274+
222275
// Sort and paginate
223276
const sortedTests = useMemo(() => {
224-
const sorted = [...allTests]
277+
const sorted = [...filteredTests]
225278
sorted.sort((a, b) => {
226279
if (sortField === 'testNumber') {
227280
// Tests without a number go to the end
@@ -238,7 +291,7 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
238291
return bVal - aVal // Highest first (fastest)
239292
})
240293
return sorted
241-
}, [allTests, sortField, sortDirection, statColumnType])
294+
}, [filteredTests, sortField, sortDirection, statColumnType])
242295

243296
const totalPages = Math.ceil(sortedTests.length / pageSize)
244297
const paginatedTests = sortedTests.slice((currentPage - 1) * pageSize, currentPage * pageSize)
@@ -310,6 +363,11 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
310363
}
311364
}
312365

366+
const handleSearchChange = (value: string) => {
367+
setCurrentPage(1)
368+
onSearchChange?.(value || undefined)
369+
}
370+
313371
const handleMouseEnter = (test: ProcessedTest, client: string, run: RunData, event: React.MouseEvent) => {
314372
const rect = event.currentTarget.getBoundingClientRect()
315373
setTooltip({
@@ -336,7 +394,8 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
336394
return (
337395
<div className="relative flex flex-col gap-4">
338396
{/* Controls */}
339-
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
397+
<div className="flex items-start gap-x-6 gap-y-2">
398+
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
340399
{/* Threshold control */}
341400
<div className="flex items-center gap-2">
342401
<span className="text-xs/5 text-gray-500 dark:text-gray-400">Slow threshold:</span>
@@ -419,6 +478,17 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
419478
</div>
420479
</div>
421480

481+
{/* Show test name toggle */}
482+
<label className="flex cursor-pointer items-center gap-1.5">
483+
<input
484+
type="checkbox"
485+
checked={showTestName}
486+
onChange={(e) => onShowTestNameChange?.(e.target.checked)}
487+
className="size-3.5 cursor-pointer rounded-xs border-gray-300 text-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
488+
/>
489+
<span className="text-xs/5 text-gray-500 dark:text-gray-400">Test name</span>
490+
</label>
491+
422492
{/* Page size selector */}
423493
<div className="flex items-center gap-2">
424494
<span className="text-xs/5 text-gray-500 dark:text-gray-400">Per page:</span>
@@ -439,6 +509,36 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
439509
))}
440510
</div>
441511
</div>
512+
513+
</div>
514+
515+
{/* Search filter */}
516+
<div className="flex shrink-0 items-center gap-1.5">
517+
<input
518+
type="text"
519+
value={search}
520+
onChange={(e) => handleSearchChange(e.target.value)}
521+
placeholder={useRegex ? 'Regex pattern...' : 'Filter tests...'}
522+
className={clsx(
523+
'w-48 rounded-sm border bg-white px-2 py-0.5 text-xs/5 text-gray-900 placeholder:text-gray-400 focus:outline-hidden focus:ring-1 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500',
524+
useRegex && search && (() => { try { new RegExp(search); return false } catch { return true } })()
525+
? 'border-red-400 focus:border-red-500 focus:ring-red-500 dark:border-red-500'
526+
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600',
527+
)}
528+
/>
529+
<button
530+
onClick={() => setUseRegex(!useRegex)}
531+
title={useRegex ? 'Regex mode (click to switch to text)' : 'Text mode (click to switch to regex)'}
532+
className={clsx(
533+
'rounded-sm px-1.5 py-0.5 font-mono text-xs/5 transition-colors',
534+
useRegex
535+
? 'bg-blue-500 text-white'
536+
: 'bg-white text-gray-500 ring-1 ring-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-600 dark:hover:bg-gray-700',
537+
)}
538+
>
539+
.*
540+
</button>
541+
</div>
442542
</div>
443543

444544
<div className="overflow-x-auto">
@@ -494,7 +594,19 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
494594
</thead>
495595
<tbody>
496596
{paginatedTests.map((test) => (
497-
<tr key={test.name} className="border-t border-gray-200 dark:border-gray-700">
597+
<Fragment key={test.name}>
598+
{showTestName && (
599+
<tr className="border-t border-gray-200 dark:border-gray-700">
600+
<td
601+
colSpan={clients.length + 2}
602+
className="truncate px-2 py-0.5 font-mono text-xs/5 text-gray-500 dark:text-gray-400"
603+
title={test.name}
604+
>
605+
<HighlightedName name={test.name} search={search} useRegex={useRegex} />
606+
</td>
607+
</tr>
608+
)}
609+
<tr className={clsx('border-t border-gray-200 dark:border-gray-700', showTestName && 'border-t-0')}>
498610
<td
499611
className="sticky left-0 z-10 bg-white px-2 py-1.5 text-right font-mono text-xs/5 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
500612
title={test.name}
@@ -585,6 +697,7 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
585697
{formatMGasCompact(statColumnType === 'Avg' ? test.avgMgas : test.minMgas)}
586698
</td>
587699
</tr>
700+
</Fragment>
588701
))}
589702
</tbody>
590703
</table>
@@ -650,7 +763,7 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
650763
No data
651764
</span>
652765
<span className="text-gray-400 dark:text-gray-500">
653-
{allTests.length} tests · {runsPerClient} most recent runs per client
766+
{search ? `${filteredTests.length} / ${allTests.length}` : allTests.length} tests · {runsPerClient} most recent runs per client
654767
</span>
655768
</div>
656769
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={handlePageChange} />

ui/src/pages/SuiteDetailPage.tsx

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ export function SuiteDetailPage() {
8383
chartPassingOnly?: string
8484
heatmapColor?: ColorNormalization
8585
steps?: string
86+
hq?: string
87+
hn?: string
8688
}
87-
const { tab, client, image, status = 'all', sortBy = 'timestamp', sortDir = 'desc', filesPage, detail, opcodeSort, q, chartMode = 'runCount', heatmapColor = 'suite' } = search
89+
const { tab, client, image, status = 'all', sortBy = 'timestamp', sortDir = 'desc', filesPage, detail, opcodeSort, q, chartMode = 'runCount', heatmapColor = 'suite', hq, hn } = search
8890
const chartPassingOnly = search.chartPassingOnly !== 'false'
8991
const stepFilter = parseStepFilter(search.steps)
9092
const { data: suite, isLoading, error, refetch } = useSuite(suiteHash)
@@ -285,6 +287,22 @@ export function SuiteDetailPage() {
285287
})
286288
}
287289

290+
const handleHeatmapSearchChange = (query: string | undefined) => {
291+
navigate({
292+
to: '/suites/$suiteHash',
293+
params: { suiteHash },
294+
search: { tab, client, image, status, sortBy, sortDir, chartMode, chartPassingOnly: chartPassingOnlyParam, heatmapColor, steps: serializeStepFilter(stepFilter), hq: query || undefined, hn },
295+
})
296+
}
297+
298+
const handleHeatmapShowNameChange = (show: boolean) => {
299+
navigate({
300+
to: '/suites/$suiteHash',
301+
params: { suiteHash },
302+
search: { tab, client, image, status, sortBy, sortDir, chartMode, chartPassingOnly: chartPassingOnlyParam, heatmapColor, steps: serializeStepFilter(stepFilter), hq, hn: show ? '1' : undefined },
303+
})
304+
}
305+
288306
const handleStepFilterChange = (steps: IndexStepType[]) => {
289307
navigate({
290308
to: '/suites/$suiteHash',
@@ -550,23 +568,6 @@ export function SuiteDetailPage() {
550568
</div>
551569
)}
552570
</div>
553-
{suiteStats && Object.keys(suiteStats).length > 0 && (
554-
<div className="overflow-hidden rounded-sm border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
555-
<button
556-
onClick={() => setSlowestTestsExpanded(!slowestTestsExpanded)}
557-
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm/6 font-medium text-gray-900 hover:bg-gray-50 dark:text-gray-100 dark:hover:bg-gray-700/50"
558-
>
559-
<ChevronRight className={clsx('size-4 text-gray-500 transition-transform', slowestTestsExpanded && 'rotate-90')} />
560-
<Flame className="size-4 text-gray-400 dark:text-gray-500" />
561-
Test Heatmap
562-
</button>
563-
{slowestTestsExpanded && (
564-
<div className="border-t border-gray-200 p-4 dark:border-gray-700">
565-
<TestHeatmap stats={suiteStats} testFiles={suite.tests} isDark={isDark} stepFilter={stepFilter} />
566-
</div>
567-
)}
568-
</div>
569-
)}
570571
<RunFilters
571572
clients={clients}
572573
selectedClient={client}
@@ -630,6 +631,23 @@ export function SuiteDetailPage() {
630631
)}
631632
</TabPanel>
632633
<TabPanel className="flex flex-col gap-4">
634+
{suiteStats && Object.keys(suiteStats).length > 0 && (
635+
<div className="overflow-hidden rounded-sm border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
636+
<button
637+
onClick={() => setSlowestTestsExpanded(!slowestTestsExpanded)}
638+
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm/6 font-medium text-gray-900 hover:bg-gray-50 dark:text-gray-100 dark:hover:bg-gray-700/50"
639+
>
640+
<ChevronRight className={clsx('size-4 text-gray-500 transition-transform', slowestTestsExpanded && 'rotate-90')} />
641+
<Flame className="size-4 text-gray-400 dark:text-gray-500" />
642+
Test Heatmap
643+
</button>
644+
{slowestTestsExpanded && (
645+
<div className="border-t border-gray-200 p-4 dark:border-gray-700">
646+
<TestHeatmap stats={suiteStats} testFiles={suite.tests} isDark={isDark} stepFilter={stepFilter} searchQuery={hq} onSearchChange={handleHeatmapSearchChange} showTestName={hn === '1'} onShowTestNameChange={handleHeatmapShowNameChange} />
647+
</div>
648+
)}
649+
</div>
650+
)}
633651
{suite.tests.some((t) => t.eest?.info?.opcode_count && Object.keys(t.eest.info.opcode_count).length > 0) && (
634652
<div className="overflow-hidden rounded-sm border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
635653
<OpcodeHeatmapSection tests={suite.tests} onTestClick={handleDetailChange} />

0 commit comments

Comments
 (0)