Skip to content

Commit 2d74b19

Browse files
authored
fix(ui): mobile-friendly improvements across all pages (#148)
## Summary - **Breadcrumbs**: flex-wrap, truncation, and proper overflow handling on all pages (RunDetail, FileViewer, Compare) - **Runs table**: compact padding, two-line timestamp, logo-only client badges, hidden image column, horizontal scroll on mobile - **Suites table**: compact layout, hidden secondary columns, two-line time format on mobile - **Pagination**: smaller buttons, wrapped controls, hidden "per page" label on mobile - **Run detail page**: stacking recent runs strip and compare buttons, wrapping heatmap squares - **Suite detail page**: compact tabs with shorter labels, compact metric step filters, mobile-friendly runs heatmap with stats table - **Test heatmap**: wrapping controls, shortened labels, smaller boxes, logo-only client badges, single-column histogram grid on mobile - **Header**: inline user menu items in mobile hamburger menu instead of popup dropdown - **Admin page**: sortable column headers on all admin tables (Users, Sessions, GitHub Mappings, API Keys) ## Test plan - [x] Verify all pages render correctly on mobile viewport (375px width) - [x] Verify tables scroll horizontally where needed - [x] Verify breadcrumbs truncate and wrap properly - [x] Verify user menu works in mobile hamburger menu - [x] Verify admin table sorting works on all tabs - [x] Verify desktop layout is unchanged
1 parent dc2d25d commit 2d74b19

15 files changed

Lines changed: 567 additions & 276 deletions

File tree

ui/src/components/layout/Header.tsx

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,78 @@ function UserMenu({ onNavigate }: { onNavigate?: () => void }) {
135135
)
136136
}
137137

138-
function AuthControls({ onNavigate }: { onNavigate?: () => void }) {
138+
function MobileUserMenu({ onNavigate }: { onNavigate?: () => void }) {
139+
const { user, isAdmin, logout } = useAuth()
140+
const navigate = useNavigate()
141+
142+
if (!user) return null
143+
144+
const handleLogout = async () => {
145+
await logout()
146+
onNavigate?.()
147+
navigate({ to: '/runs' })
148+
}
149+
150+
const handleNavigate = (to: string) => {
151+
onNavigate?.()
152+
navigate({ to })
153+
}
154+
155+
return (
156+
<div className="flex flex-col gap-1">
157+
<div className="flex items-center gap-2 px-3 py-1.5 text-sm/6 font-medium text-gray-900 dark:text-gray-100">
158+
{user.source === 'github' ? (
159+
<img src={`https://github.com/${user.username}.png`} alt="" className="size-6 rounded-full" />
160+
) : (
161+
<User className="size-4" />
162+
)}
163+
<span>{user.username}</span>
164+
{isAdmin && <Shield className="size-3 text-purple-500" />}
165+
</div>
166+
<div className="ml-5 flex flex-col gap-1 border-l border-gray-200 pl-3 dark:border-gray-700">
167+
<button
168+
onClick={() => handleNavigate('/api-keys')}
169+
className="flex items-center gap-2 rounded-sm px-3 py-1.5 text-left text-sm/6 text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/50 dark:hover:text-gray-100"
170+
>
171+
<Shield className="size-3.5" />
172+
API Keys
173+
</button>
174+
<button
175+
onClick={() => handleNavigate('/api-docs')}
176+
className="flex items-center gap-2 rounded-sm px-3 py-1.5 text-left text-sm/6 text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/50 dark:hover:text-gray-100"
177+
>
178+
<FileText className="size-3.5" />
179+
API Docs
180+
</button>
181+
<button
182+
onClick={() => handleNavigate('/query')}
183+
className="flex items-center gap-2 rounded-sm px-3 py-1.5 text-left text-sm/6 text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/50 dark:hover:text-gray-100"
184+
>
185+
<Search className="size-3.5" />
186+
Query Builder
187+
</button>
188+
{isAdmin && (
189+
<button
190+
onClick={() => handleNavigate('/admin')}
191+
className="flex items-center gap-2 rounded-sm px-3 py-1.5 text-left text-sm/6 text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/50 dark:hover:text-gray-100"
192+
>
193+
<User className="size-3.5" />
194+
Admin
195+
</button>
196+
)}
197+
<button
198+
onClick={handleLogout}
199+
className="flex items-center gap-2 rounded-sm px-3 py-1.5 text-left text-sm/6 text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/50 dark:hover:text-gray-100"
200+
>
201+
<LogOut className="size-3.5" />
202+
Sign out
203+
</button>
204+
</div>
205+
</div>
206+
)
207+
}
208+
209+
function AuthControls({ onNavigate, variant = 'desktop' }: { onNavigate?: () => void; variant?: 'desktop' | 'mobile' }) {
139210
const { user, isApiEnabled } = useAuth()
140211

141212
if (!isApiEnabled) return null
@@ -153,6 +224,10 @@ function AuthControls({ onNavigate }: { onNavigate?: () => void }) {
153224
)
154225
}
155226

227+
if (variant === 'mobile') {
228+
return <MobileUserMenu onNavigate={onNavigate} />
229+
}
230+
156231
return <UserMenu onNavigate={onNavigate} />
157232
}
158233

@@ -197,9 +272,11 @@ export function Header() {
197272
<NavLink to="/runs" onClick={closeMobile}>Runs</NavLink>
198273
<NavLink to="/suites" onClick={closeMobile}>Suites</NavLink>
199274
</nav>
200-
<div className="mt-3 flex items-center gap-2 border-t border-gray-200 pt-3 dark:border-gray-700">
201-
<AuthControls onNavigate={closeMobile} />
202-
<ThemeSwitcher />
275+
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
276+
<AuthControls onNavigate={closeMobile} variant="mobile" />
277+
<div className="mt-2 flex items-center justify-end">
278+
<ThemeSwitcher />
279+
</div>
203280
</div>
204281
</div>
205282
)}

ui/src/components/run-detail/ClientRunsStrip.tsx

Lines changed: 66 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -81,69 +81,73 @@ export function ClientRunsStrip({ runs, currentRunId, stepFilter, selectable = f
8181
if (displayRuns.length <= 1) return null
8282

8383
return (
84-
<div className="relative flex items-center gap-3 rounded-sm bg-white px-4 py-3 shadow-xs dark:bg-gray-800">
85-
<span className="shrink-0 text-xs/5 text-gray-400 dark:text-gray-500">Recent</span>
86-
<div className="flex gap-1">
87-
{displayRuns.map((run) => {
88-
const stats = getIndexAggregatedStats(run, stepFilter)
89-
const completed = isRunCompleted(run)
90-
const mgas = calculateMGasPerSec(stats.gasUsed, stats.gasUsedDuration)
91-
const color = completed && mgas !== undefined
92-
? getColorByNormalizedValue(mgas, minMgas, maxMgas, true)
93-
: completed ? COLORS[2] : '#6b7280'
94-
const isCurrent = run.run_id === currentRunId
95-
96-
return (
97-
<button
98-
key={run.run_id}
99-
onClick={() => {
100-
if (selectable) {
101-
onSelectionChange?.(run.run_id, !selectedRunIds?.has(run.run_id))
102-
} else {
103-
navigate({ to: '/runs/$runId', params: { runId: run.run_id } })
104-
}
105-
}}
106-
onMouseEnter={(e) => {
107-
const rect = e.currentTarget.getBoundingClientRect()
108-
setTooltip({ run, x: rect.left + rect.width / 2, y: rect.top })
109-
}}
110-
onMouseLeave={() => setTooltip(null)}
111-
className={clsx(
112-
'relative size-5 shrink-0 cursor-pointer rounded-xs transition-all hover:scale-110',
113-
selectable && selectedRunIds?.has(run.run_id) && 'scale-110 ring-2 ring-blue-500 dark:ring-blue-400',
114-
!selectable && isCurrent && 'ring-2 ring-blue-500',
115-
!selectable && !isCurrent && 'hover:ring-2 hover:ring-gray-400 dark:hover:ring-gray-500',
116-
run.tests.tests_total - run.tests.tests_passed > 0 && completed && !isCurrent && !selectedRunIds?.has(run.run_id) && 'ring-2 ring-inset ring-orange-500',
117-
!completed && !isCurrent && !selectedRunIds?.has(run.run_id) && 'ring-2 ring-inset ring-red-600 dark:ring-red-500',
118-
)}
119-
style={{ backgroundColor: color }}
120-
>
121-
{completed && run.tests.tests_total - run.tests.tests_passed > 0 && (
122-
<svg className="absolute inset-0 size-5" viewBox="0 0 20 20" fill="none">
123-
<text x="10" y="15" textAnchor="middle" fill="white" fontSize="13" fontWeight="bold" fontFamily="system-ui">!</text>
124-
</svg>
125-
)}
126-
{!completed && (
127-
<svg className="absolute inset-0 size-5 text-red-600 dark:text-red-400" viewBox="0 0 20 20">
128-
<path d="M4 4l12 12M4 16L16 4" stroke="currentColor" strokeWidth="2" fill="none" />
129-
</svg>
130-
)}
131-
</button>
132-
)
133-
})}
134-
</div>
135-
<span className="shrink-0 text-xs/5 text-gray-400 dark:text-gray-500">Older</span>
136-
<div className="ml-2 flex items-center gap-1 border-l border-gray-200 pl-3 dark:border-gray-700">
137-
<span className="flex gap-0.5">
138-
{COLORS.map((color, i) => (
139-
<span key={i} className="size-3 rounded-xs" style={{ backgroundColor: color }} />
140-
))}
141-
</span>
142-
<span className="ml-1 text-xs/5 text-gray-400 dark:text-gray-500">MGas/s</span>
84+
<div className="relative flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xs bg-white px-4 py-3 shadow-xs dark:bg-gray-800">
85+
<div className="flex min-w-0 items-center gap-3">
86+
<span className="hidden shrink-0 text-xs/5 text-gray-400 sm:inline dark:text-gray-500">Recent</span>
87+
<div className="flex flex-wrap gap-1">
88+
{displayRuns.map((run) => {
89+
const stats = getIndexAggregatedStats(run, stepFilter)
90+
const completed = isRunCompleted(run)
91+
const mgas = calculateMGasPerSec(stats.gasUsed, stats.gasUsedDuration)
92+
const color = completed && mgas !== undefined
93+
? getColorByNormalizedValue(mgas, minMgas, maxMgas, true)
94+
: completed ? COLORS[2] : '#6b7280'
95+
const isCurrent = run.run_id === currentRunId
96+
97+
return (
98+
<button
99+
key={run.run_id}
100+
onClick={() => {
101+
if (selectable) {
102+
onSelectionChange?.(run.run_id, !selectedRunIds?.has(run.run_id))
103+
} else {
104+
navigate({ to: '/runs/$runId', params: { runId: run.run_id } })
105+
}
106+
}}
107+
onMouseEnter={(e) => {
108+
const rect = e.currentTarget.getBoundingClientRect()
109+
setTooltip({ run, x: rect.left + rect.width / 2, y: rect.top })
110+
}}
111+
onMouseLeave={() => setTooltip(null)}
112+
className={clsx(
113+
'relative size-5 shrink-0 cursor-pointer rounded-xs transition-all hover:scale-110',
114+
selectable && selectedRunIds?.has(run.run_id) && 'scale-110 ring-2 ring-blue-500 dark:ring-blue-400',
115+
!selectable && isCurrent && 'ring-2 ring-blue-500',
116+
!selectable && !isCurrent && 'hover:ring-2 hover:ring-gray-400 dark:hover:ring-gray-500',
117+
run.tests.tests_total - run.tests.tests_passed > 0 && completed && !isCurrent && !selectedRunIds?.has(run.run_id) && 'ring-2 ring-inset ring-orange-500',
118+
!completed && !isCurrent && !selectedRunIds?.has(run.run_id) && 'ring-2 ring-inset ring-red-600 dark:ring-red-500',
119+
)}
120+
style={{ backgroundColor: color }}
121+
>
122+
{completed && run.tests.tests_total - run.tests.tests_passed > 0 && (
123+
<svg className="absolute inset-0 size-5" viewBox="0 0 20 20" fill="none">
124+
<text x="10" y="15" textAnchor="middle" fill="white" fontSize="13" fontWeight="bold" fontFamily="system-ui">!</text>
125+
</svg>
126+
)}
127+
{!completed && (
128+
<svg className="absolute inset-0 size-5 text-red-600 dark:text-red-400" viewBox="0 0 20 20">
129+
<path d="M4 4l12 12M4 16L16 4" stroke="currentColor" strokeWidth="2" fill="none" />
130+
</svg>
131+
)}
132+
</button>
133+
)
134+
})}
135+
</div>
136+
<span className="hidden shrink-0 text-xs/5 text-gray-400 sm:inline dark:text-gray-500">Older</span>
143137
</div>
144-
<div className="ml-2 flex items-center gap-1 border-l border-gray-200 pl-3 dark:border-gray-700">
145-
<span className="size-3 rounded-xs bg-blue-500 ring-2 ring-blue-500" />
146-
<span className="text-xs/5 text-gray-400 dark:text-gray-500">Current</span>
138+
<div className="flex items-center gap-3">
139+
<div className="flex items-center gap-1 sm:border-l sm:border-gray-200 sm:pl-3 sm:dark:border-gray-700">
140+
<span className="flex gap-0.5">
141+
{COLORS.map((color, i) => (
142+
<span key={i} className="size-3 rounded-xs" style={{ backgroundColor: color }} />
143+
))}
144+
</span>
145+
<span className="ml-1 text-xs/5 text-gray-400 dark:text-gray-500">MGas/s</span>
146+
</div>
147+
<div className="flex items-center gap-1 border-l border-gray-200 pl-3 dark:border-gray-700">
148+
<span className="size-3 rounded-xs bg-blue-500 ring-2 ring-blue-500" />
149+
<span className="text-xs/5 text-gray-400 dark:text-gray-500">Current</span>
150+
</div>
147151
</div>
148152

149153
{tooltip && (() => {

0 commit comments

Comments
 (0)