Add comprehensive column sorting with icons and tooltips to RequestBreakdown

Enhance all breakdown sections (Resource Type, Status Code, Hostname) with:
- Clickable column headers for sorting by any field
- Visual sort indicators: neutral (⇅), ascending (▲), descending (▼)
- Interactive tooltips showing current sort state and available actions
- Default sort by request count in descending order
- Toggle between ascending/descending by clicking same column
- Consistent styling with brand colors and hover effects
- Proper flexbox layout for icon positioning

All 8 columns are sortable: name, count, count %, size, size %,
response time, response time %, and average response time.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-08-19 09:08:14 -05:00
parent 1035b28472
commit b5b7aafca5
6 changed files with 395 additions and 29 deletions

View File

@ -1,21 +1,121 @@
import React from 'react' import React from 'react'
import styles from './RequestBreakdown.module.css' import styles from './RequestBreakdown.module.css'
export type SortColumn = 'name' | 'count' | 'percentage' | 'totalSize' | 'sizePercentage' | 'totalResponseTime' | 'responseTimePercentage' | 'averageResponseTime'
export type SortDirection = 'asc' | 'desc'
interface BreakdownTableHeaderProps { interface BreakdownTableHeaderProps {
categoryLabel: string categoryLabel: string
sortColumn: SortColumn | null
sortDirection: SortDirection
onSort: (column: SortColumn) => void
}
const BreakdownTableHeader: React.FC<BreakdownTableHeaderProps> = ({
categoryLabel,
sortColumn,
sortDirection,
onSort
}) => {
const getSortIcon = (column: SortColumn) => {
if (sortColumn !== column) {
return <span className={styles.sortIconNeutral}></span>
}
return sortDirection === 'asc' ?
<span className={styles.sortIconAsc}></span> :
<span className={styles.sortIconDesc}></span>
}
const getTooltipText = (column: SortColumn, label: string) => {
if (sortColumn === column) {
const oppositeDirection = sortDirection === 'asc' ? 'descending' : 'ascending'
return `Click to sort ${label.toLowerCase()} ${oppositeDirection}`
}
return `Click to sort by ${label.toLowerCase()}`
} }
const BreakdownTableHeader: React.FC<BreakdownTableHeaderProps> = ({ categoryLabel }) => {
return ( return (
<div className={styles.tableHeader}> <div className={styles.tableHeader}>
<span>{categoryLabel}</span> <button
<span>Request Count</span> className={styles.sortButton}
<span>Request Count %</span> onClick={() => onSort('name')}
<span>Total Size</span> title={getTooltipText('name', categoryLabel)}
<span>Total Size %</span> >
<span>Total Response Time</span> <span className={styles.sortButtonContent}>
<span>Total Response Time %</span> {categoryLabel}
<span>Avg Response Time</span> {getSortIcon('name')}
</span>
</button>
<button
className={styles.sortButton}
onClick={() => onSort('count')}
title={getTooltipText('count', 'Request Count')}
>
<span className={styles.sortButtonContent}>
Request Count
{getSortIcon('count')}
</span>
</button>
<button
className={styles.sortButton}
onClick={() => onSort('percentage')}
title={getTooltipText('percentage', 'Request Count %')}
>
<span className={styles.sortButtonContent}>
Request Count %
{getSortIcon('percentage')}
</span>
</button>
<button
className={styles.sortButton}
onClick={() => onSort('totalSize')}
title={getTooltipText('totalSize', 'Total Size')}
>
<span className={styles.sortButtonContent}>
Total Size
{getSortIcon('totalSize')}
</span>
</button>
<button
className={styles.sortButton}
onClick={() => onSort('sizePercentage')}
title={getTooltipText('sizePercentage', 'Total Size %')}
>
<span className={styles.sortButtonContent}>
Total Size %
{getSortIcon('sizePercentage')}
</span>
</button>
<button
className={styles.sortButton}
onClick={() => onSort('totalResponseTime')}
title={getTooltipText('totalResponseTime', 'Total Response Time')}
>
<span className={styles.sortButtonContent}>
Total Response Time
{getSortIcon('totalResponseTime')}
</span>
</button>
<button
className={styles.sortButton}
onClick={() => onSort('responseTimePercentage')}
title={getTooltipText('responseTimePercentage', 'Total Response Time %')}
>
<span className={styles.sortButtonContent}>
Total Response Time %
{getSortIcon('responseTimePercentage')}
</span>
</button>
<button
className={styles.sortButton}
onClick={() => onSort('averageResponseTime')}
title={getTooltipText('averageResponseTime', 'Average Response Time')}
>
<span className={styles.sortButtonContent}>
Avg Response Time
{getSortIcon('averageResponseTime')}
</span>
</button>
</div> </div>
) )
} }

View File

@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import type { HTTPRequest } from '../httprequestviewer/types/httpRequest' import type { HTTPRequest } from '../httprequestviewer/types/httpRequest'
import BreakdownTableHeader from './BreakdownTableHeader' import BreakdownTableHeader, { type SortColumn, type SortDirection } from './BreakdownTableHeader'
import styles from './RequestBreakdown.module.css' import styles from './RequestBreakdown.module.css'
interface CategoryBreakdown { interface CategoryBreakdown {
@ -20,6 +20,8 @@ interface HostnameBreakdownProps {
const HostnameBreakdown: React.FC<HostnameBreakdownProps> = ({ httpRequests }) => { const HostnameBreakdown: React.FC<HostnameBreakdownProps> = ({ httpRequests }) => {
const [showAllHostnames, setShowAllHostnames] = useState(false) const [showAllHostnames, setShowAllHostnames] = useState(false)
const [sortColumn, setSortColumn] = useState<SortColumn | null>('count')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
const formatSize = (bytes: number): string => { const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
@ -33,7 +35,7 @@ const HostnameBreakdown: React.FC<HostnameBreakdownProps> = ({ httpRequests }) =
return `${(microseconds / 1000000).toFixed(2)} s` return `${(microseconds / 1000000).toFixed(2)} s`
} }
const hostnameBreakdown: CategoryBreakdown[] = useMemo(() => { const baseHostnameBreakdown: CategoryBreakdown[] = useMemo(() => {
const hostMap = new Map<string, HTTPRequest[]>() const hostMap = new Map<string, HTTPRequest[]>()
httpRequests.forEach(req => { httpRequests.forEach(req => {
@ -72,15 +74,83 @@ const HostnameBreakdown: React.FC<HostnameBreakdownProps> = ({ httpRequests }) =
responseTimePercentage: totalAllResponseTime > 0 ? (totalResponseTime / totalAllResponseTime) * 100 : 0, responseTimePercentage: totalAllResponseTime > 0 ? (totalResponseTime / totalAllResponseTime) * 100 : 0,
averageResponseTime: totalResponseTime / requests.length averageResponseTime: totalResponseTime / requests.length
} }
}).sort((a, b) => b.count - a.count) })
}, [httpRequests]) }, [httpRequests])
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDirection('desc')
}
}
const sortedHostnameBreakdown = useMemo(() => {
if (!sortColumn) return baseHostnameBreakdown
return [...baseHostnameBreakdown].sort((a, b) => {
let aValue: string | number
let bValue: string | number
switch (sortColumn) {
case 'name':
aValue = a.name
bValue = b.name
break
case 'count':
aValue = a.count
bValue = b.count
break
case 'percentage':
aValue = a.percentage
bValue = b.percentage
break
case 'totalSize':
aValue = a.totalSize
bValue = b.totalSize
break
case 'sizePercentage':
aValue = a.sizePercentage
bValue = b.sizePercentage
break
case 'totalResponseTime':
aValue = a.totalResponseTime
bValue = b.totalResponseTime
break
case 'responseTimePercentage':
aValue = a.responseTimePercentage
bValue = b.responseTimePercentage
break
case 'averageResponseTime':
aValue = a.averageResponseTime
bValue = b.averageResponseTime
break
default:
return 0
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue)
return sortDirection === 'asc' ? comparison : -comparison
} else {
const comparison = (aValue as number) - (bValue as number)
return sortDirection === 'asc' ? comparison : -comparison
}
})
}, [baseHostnameBreakdown, sortColumn, sortDirection])
return ( return (
<div className={styles.breakdownSection}> <div className={styles.breakdownSection}>
<h3>By Hostname</h3> <h3>By Hostname</h3>
<div className={styles.breakdownTable}> <div className={styles.breakdownTable}>
<BreakdownTableHeader categoryLabel="Hostname" /> <BreakdownTableHeader
{(showAllHostnames ? hostnameBreakdown : hostnameBreakdown.slice(0, 10)).map(item => ( categoryLabel="Hostname"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
{(showAllHostnames ? sortedHostnameBreakdown : sortedHostnameBreakdown.slice(0, 10)).map(item => (
<div key={item.name} className={styles.tableRow}> <div key={item.name} className={styles.tableRow}>
<span className={styles.categoryName}>{item.name}</span> <span className={styles.categoryName}>{item.name}</span>
<span>{item.count}</span> <span>{item.count}</span>
@ -117,11 +187,11 @@ const HostnameBreakdown: React.FC<HostnameBreakdownProps> = ({ httpRequests }) =
</div> </div>
))} ))}
</div> </div>
{hostnameBreakdown.length > 10 && ( {sortedHostnameBreakdown.length > 10 && (
<div className={styles.moreInfo}> <div className={styles.moreInfo}>
{showAllHostnames ? ( {showAllHostnames ? (
<> <>
Showing all {hostnameBreakdown.length} hostnames Showing all {sortedHostnameBreakdown.length} hostnames
<button <button
onClick={() => setShowAllHostnames(false)} onClick={() => setShowAllHostnames(false)}
className={styles.toggleButton} className={styles.toggleButton}
@ -131,7 +201,7 @@ const HostnameBreakdown: React.FC<HostnameBreakdownProps> = ({ httpRequests }) =
</> </>
) : ( ) : (
<> <>
Showing top 10 of {hostnameBreakdown.length} hostnames Showing top 10 of {sortedHostnameBreakdown.length} hostnames
<button <button
onClick={() => setShowAllHostnames(true)} onClick={() => setShowAllHostnames(true)}
className={styles.toggleButton} className={styles.toggleButton}

View File

@ -107,6 +107,61 @@
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
} }
.sortButton {
background: none;
border: none;
font-size: inherit;
font-weight: inherit;
color: inherit;
text-transform: inherit;
letter-spacing: inherit;
cursor: pointer;
text-align: left;
padding: 0;
transition: color 0.2s ease;
}
.sortButton:hover {
color: #667eea;
}
.sortButtonContent {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 4px;
}
.sortIconNeutral,
.sortIconAsc,
.sortIconDesc {
font-size: 12px;
opacity: 0.6;
transition: opacity 0.2s ease;
flex-shrink: 0;
}
.sortIconNeutral {
opacity: 0.3;
}
.sortIconAsc,
.sortIconDesc {
opacity: 0.8;
color: #667eea;
}
.sortButton:hover .sortIconNeutral {
opacity: 0.8;
color: #667eea;
}
.sortButton:hover .sortIconAsc,
.sortButton:hover .sortIconDesc {
opacity: 1;
}
.tableRow { .tableRow {
display: grid; display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr 1fr 1.5fr 1fr 1.5fr; grid-template-columns: 2fr 1fr 1fr 1.5fr 1fr 1.5fr 1fr 1.5fr;

View File

@ -1,6 +1,6 @@
import React, { useMemo } from 'react' import React, { useMemo, useState } from 'react'
import type { HTTPRequest } from '../httprequestviewer/types/httpRequest' import type { HTTPRequest } from '../httprequestviewer/types/httpRequest'
import BreakdownTableHeader from './BreakdownTableHeader' import BreakdownTableHeader, { type SortColumn, type SortDirection } from './BreakdownTableHeader'
import styles from './RequestBreakdown.module.css' import styles from './RequestBreakdown.module.css'
interface CategoryBreakdown { interface CategoryBreakdown {
@ -19,6 +19,8 @@ interface ResourceTypeBreakdownProps {
} }
const ResourceTypeBreakdown: React.FC<ResourceTypeBreakdownProps> = ({ httpRequests }) => { const ResourceTypeBreakdown: React.FC<ResourceTypeBreakdownProps> = ({ httpRequests }) => {
const [sortColumn, setSortColumn] = useState<SortColumn | null>('count')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
const formatSize = (bytes: number): string => { const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
@ -31,7 +33,7 @@ const ResourceTypeBreakdown: React.FC<ResourceTypeBreakdownProps> = ({ httpReque
return `${(microseconds / 1000000).toFixed(2)} s` return `${(microseconds / 1000000).toFixed(2)} s`
} }
const resourceTypeBreakdown: CategoryBreakdown[] = useMemo(() => { const baseResourceTypeBreakdown: CategoryBreakdown[] = useMemo(() => {
const typeMap = new Map<string, HTTPRequest[]>() const typeMap = new Map<string, HTTPRequest[]>()
httpRequests.forEach(req => { httpRequests.forEach(req => {
@ -70,15 +72,83 @@ const ResourceTypeBreakdown: React.FC<ResourceTypeBreakdownProps> = ({ httpReque
responseTimePercentage: totalAllResponseTime > 0 ? (totalResponseTime / totalAllResponseTime) * 100 : 0, responseTimePercentage: totalAllResponseTime > 0 ? (totalResponseTime / totalAllResponseTime) * 100 : 0,
averageResponseTime: totalResponseTime / requests.length averageResponseTime: totalResponseTime / requests.length
} }
}).sort((a, b) => b.count - a.count) })
}, [httpRequests]) }, [httpRequests])
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDirection('desc')
}
}
const sortedResourceTypeBreakdown = useMemo(() => {
if (!sortColumn) return baseResourceTypeBreakdown
return [...baseResourceTypeBreakdown].sort((a, b) => {
let aValue: string | number
let bValue: string | number
switch (sortColumn) {
case 'name':
aValue = a.name
bValue = b.name
break
case 'count':
aValue = a.count
bValue = b.count
break
case 'percentage':
aValue = a.percentage
bValue = b.percentage
break
case 'totalSize':
aValue = a.totalSize
bValue = b.totalSize
break
case 'sizePercentage':
aValue = a.sizePercentage
bValue = b.sizePercentage
break
case 'totalResponseTime':
aValue = a.totalResponseTime
bValue = b.totalResponseTime
break
case 'responseTimePercentage':
aValue = a.responseTimePercentage
bValue = b.responseTimePercentage
break
case 'averageResponseTime':
aValue = a.averageResponseTime
bValue = b.averageResponseTime
break
default:
return 0
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue)
return sortDirection === 'asc' ? comparison : -comparison
} else {
const comparison = (aValue as number) - (bValue as number)
return sortDirection === 'asc' ? comparison : -comparison
}
})
}, [baseResourceTypeBreakdown, sortColumn, sortDirection])
return ( return (
<div className={styles.breakdownSection}> <div className={styles.breakdownSection}>
<h3>By Resource Type</h3> <h3>By Resource Type</h3>
<div className={styles.breakdownTable}> <div className={styles.breakdownTable}>
<BreakdownTableHeader categoryLabel="Resource Type" /> <BreakdownTableHeader
{resourceTypeBreakdown.map(item => ( categoryLabel="Resource Type"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
{sortedResourceTypeBreakdown.map(item => (
<div key={item.name} className={styles.tableRow}> <div key={item.name} className={styles.tableRow}>
<span className={styles.categoryName}>{item.name}</span> <span className={styles.categoryName}>{item.name}</span>
<span>{item.count}</span> <span>{item.count}</span>

View File

@ -1,6 +1,6 @@
import React, { useMemo } from 'react' import React, { useMemo, useState } from 'react'
import type { HTTPRequest } from '../httprequestviewer/types/httpRequest' import type { HTTPRequest } from '../httprequestviewer/types/httpRequest'
import BreakdownTableHeader from './BreakdownTableHeader' import BreakdownTableHeader, { type SortColumn, type SortDirection } from './BreakdownTableHeader'
import styles from './RequestBreakdown.module.css' import styles from './RequestBreakdown.module.css'
interface CategoryBreakdown { interface CategoryBreakdown {
@ -19,6 +19,8 @@ interface StatusCodeBreakdownProps {
} }
const StatusCodeBreakdown: React.FC<StatusCodeBreakdownProps> = ({ httpRequests }) => { const StatusCodeBreakdown: React.FC<StatusCodeBreakdownProps> = ({ httpRequests }) => {
const [sortColumn, setSortColumn] = useState<SortColumn | null>('count')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
const formatSize = (bytes: number): string => { const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
@ -31,7 +33,7 @@ const StatusCodeBreakdown: React.FC<StatusCodeBreakdownProps> = ({ httpRequests
return `${(microseconds / 1000000).toFixed(2)} s` return `${(microseconds / 1000000).toFixed(2)} s`
} }
const statusCodeBreakdown: CategoryBreakdown[] = useMemo(() => { const baseStatusCodeBreakdown: CategoryBreakdown[] = useMemo(() => {
const statusMap = new Map<string, HTTPRequest[]>() const statusMap = new Map<string, HTTPRequest[]>()
httpRequests.forEach(req => { httpRequests.forEach(req => {
@ -70,15 +72,83 @@ const StatusCodeBreakdown: React.FC<StatusCodeBreakdownProps> = ({ httpRequests
responseTimePercentage: totalAllResponseTime > 0 ? (totalResponseTime / totalAllResponseTime) * 100 : 0, responseTimePercentage: totalAllResponseTime > 0 ? (totalResponseTime / totalAllResponseTime) * 100 : 0,
averageResponseTime: totalResponseTime / requests.length averageResponseTime: totalResponseTime / requests.length
} }
}).sort((a, b) => b.count - a.count) })
}, [httpRequests]) }, [httpRequests])
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDirection('desc')
}
}
const sortedStatusCodeBreakdown = useMemo(() => {
if (!sortColumn) return baseStatusCodeBreakdown
return [...baseStatusCodeBreakdown].sort((a, b) => {
let aValue: string | number
let bValue: string | number
switch (sortColumn) {
case 'name':
aValue = a.name
bValue = b.name
break
case 'count':
aValue = a.count
bValue = b.count
break
case 'percentage':
aValue = a.percentage
bValue = b.percentage
break
case 'totalSize':
aValue = a.totalSize
bValue = b.totalSize
break
case 'sizePercentage':
aValue = a.sizePercentage
bValue = b.sizePercentage
break
case 'totalResponseTime':
aValue = a.totalResponseTime
bValue = b.totalResponseTime
break
case 'responseTimePercentage':
aValue = a.responseTimePercentage
bValue = b.responseTimePercentage
break
case 'averageResponseTime':
aValue = a.averageResponseTime
bValue = b.averageResponseTime
break
default:
return 0
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue)
return sortDirection === 'asc' ? comparison : -comparison
} else {
const comparison = (aValue as number) - (bValue as number)
return sortDirection === 'asc' ? comparison : -comparison
}
})
}, [baseStatusCodeBreakdown, sortColumn, sortDirection])
return ( return (
<div className={styles.breakdownSection}> <div className={styles.breakdownSection}>
<h3>By Status Code</h3> <h3>By Status Code</h3>
<div className={styles.breakdownTable}> <div className={styles.breakdownTable}>
<BreakdownTableHeader categoryLabel="Status Code" /> <BreakdownTableHeader
{statusCodeBreakdown.map(item => ( categoryLabel="Status Code"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
{sortedStatusCodeBreakdown.map(item => (
<div key={item.name} className={styles.tableRow}> <div key={item.name} className={styles.tableRow}>
<span className={styles.categoryName}>{item.name}</span> <span className={styles.categoryName}>{item.name}</span>
<span>{item.count}</span> <span>{item.count}</span>

View File

@ -1,3 +1,4 @@
/** /**
* Chrome DevTools Performance Trace Format * Chrome DevTools Performance Trace Format
* Based on the Trace Event Format specification * Based on the Trace Event Format specification