Add Request Breakdown page with comprehensive data analysis

Introduces new breakdown page that provides detailed statistics and visualizations for HTTP request data analysis across multiple dimensions.

Features:
- Overall statistics dashboard (total requests, size, success rate, cache hit rate)
- Resource type breakdown with visual progress bars
- Status code analysis by HTTP status categories
- Hostname breakdown showing top 10 domains
- Total and average response time metrics
- Responsive design with mobile-friendly layouts
- Added to main navigation as "Request Breakdown" tab

🤖 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 07:52:22 -05:00
parent 327ef29d55
commit 33fb2b1674
3 changed files with 595 additions and 3 deletions

View File

@ -4,13 +4,14 @@ import TraceViewer from './components/TraceViewer'
import PhaseViewer from './components/PhaseViewer' import PhaseViewer from './components/PhaseViewer'
import HTTPRequestViewer from './components/httprequestviewer/HTTPRequestViewer' import HTTPRequestViewer from './components/httprequestviewer/HTTPRequestViewer'
import JavaScriptViewer from './components/javascriptviewer/JavaScriptViewer' import JavaScriptViewer from './components/javascriptviewer/JavaScriptViewer'
import RequestBreakdown from './components/RequestBreakdown'
import RequestDebugger from './components/RequestDebugger' import RequestDebugger from './components/RequestDebugger'
import TraceUpload from './components/TraceUpload' import TraceUpload from './components/TraceUpload'
import TraceSelector from './components/TraceSelector' import TraceSelector from './components/TraceSelector'
import { traceDatabase } from './utils/traceDatabase' import { traceDatabase } from './utils/traceDatabase'
import { useDatabaseTraceData } from './hooks/useDatabaseTraceData' import { useDatabaseTraceData } from './hooks/useDatabaseTraceData'
type AppView = 'trace' | 'phases' | 'http' | 'js' | 'debug' type AppView = 'trace' | 'phases' | 'http' | 'js' | 'breakdown' | 'debug'
type AppMode = 'selector' | 'upload' | 'analysis' type AppMode = 'selector' | 'upload' | 'analysis'
type ThreeDView = 'network' | 'timeline' | null type ThreeDView = 'network' | 'timeline' | null
@ -34,7 +35,7 @@ const getUrlParams = () => {
const view = segments[1] as AppView const view = segments[1] as AppView
const threeDView = segments[2] as ThreeDView const threeDView = segments[2] as ThreeDView
// Validate view and 3D view values // Validate view and 3D view values
const validViews: AppView[] = ['trace', 'phases', 'http', 'js', 'debug'] const validViews: AppView[] = ['trace', 'phases', 'http', 'js', 'breakdown', 'debug']
const validThreeDViews: (ThreeDView)[] = ['network', 'timeline'] const validThreeDViews: (ThreeDView)[] = ['network', 'timeline']
const validatedView = validViews.includes(view) ? view : 'http' const validatedView = validViews.includes(view) ? view : 'http'
const validatedThreeDView = validThreeDViews.includes(threeDView) ? threeDView : null const validatedThreeDView = validThreeDViews.includes(threeDView) ? threeDView : null
@ -43,7 +44,7 @@ const getUrlParams = () => {
const traceId = segments[0] const traceId = segments[0]
const view = segments[1] as AppView const view = segments[1] as AppView
// Validate view is one of the allowed values // Validate view is one of the allowed values
const validViews: AppView[] = ['trace', 'phases', 'http', 'js', 'debug'] const validViews: AppView[] = ['trace', 'phases', 'http', 'js', 'breakdown', 'debug']
const validatedView = validViews.includes(view) ? view : 'http' const validatedView = validViews.includes(view) ? view : 'http'
return { traceId, view: validatedView, threeDView: null } return { traceId, view: validatedView, threeDView: null }
} else if (segments.length === 1) { } else if (segments.length === 1) {
@ -229,6 +230,15 @@ function App() {
> >
JavaScript JavaScript
</button> </button>
<button
className={`${styles.navButton} ${currentView === 'breakdown' ? styles.active : ''}`}
onClick={() => {
setCurrentView('breakdown')
updateUrlWithTraceId(selectedTraceId, 'breakdown', null)
}}
>
Request Breakdown
</button>
<button <button
className={`${styles.navButton} ${currentView === 'debug' ? styles.active : ''}`} className={`${styles.navButton} ${currentView === 'debug' ? styles.active : ''}`}
onClick={() => { onClick={() => {
@ -257,6 +267,10 @@ function App() {
<JavaScriptViewer traceId={selectedTraceId} /> <JavaScriptViewer traceId={selectedTraceId} />
)} )}
{currentView === 'breakdown' && (
<RequestBreakdown traceId={selectedTraceId} />
)}
{currentView === 'debug' && traceData && ( {currentView === 'debug' && traceData && (
<RequestDebugger traceEvents={traceData.traceEvents} /> <RequestDebugger traceEvents={traceData.traceEvents} />
)} )}

View File

@ -0,0 +1,234 @@
.container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.container h2 {
margin-bottom: 30px;
color: #333;
font-size: 28px;
font-weight: 600;
}
.loading, .error {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
font-size: 16px;
}
.error {
flex-direction: column;
background: #fee;
border: 1px solid #fcc;
padding: 40px;
border-radius: 8px;
text-align: center;
}
.error h3 {
color: #c33;
margin-bottom: 10px;
}
/* Overall Stats Grid */
.statsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.statCard {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 24px;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.statCard:hover {
transform: translateY(-2px);
}
.statCard h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 500;
opacity: 0.9;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.statValue {
font-size: 32px;
font-weight: 700;
margin: 0;
}
/* Breakdown Sections */
.breakdownSection {
margin-bottom: 40px;
}
.breakdownSection h3 {
margin-bottom: 20px;
color: #333;
font-size: 22px;
font-weight: 600;
border-bottom: 2px solid #e0e0e0;
padding-bottom: 10px;
}
.breakdownTable {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.tableHeader {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr 1.5fr 1.5fr;
gap: 16px;
padding: 16px 20px;
background: #f8f9fa;
font-weight: 600;
font-size: 14px;
color: #555;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #e0e0e0;
}
.tableRow {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr 1.5fr 1.5fr;
gap: 16px;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
transition: background-color 0.2s ease;
}
.tableRow:hover {
background-color: #f8f9fa;
}
.tableRow:last-child {
border-bottom: none;
}
.categoryName {
font-weight: 600;
color: #333;
}
.percentageCell {
display: flex;
flex-direction: column;
gap: 4px;
}
.progressBar {
width: 100%;
height: 6px;
background-color: #e0e0e0;
border-radius: 3px;
overflow: hidden;
}
.progressFill {
height: 100%;
background: linear-gradient(90deg, #4CAF50 0%, #45a049 100%);
border-radius: 3px;
transition: width 0.3s ease;
}
.moreInfo {
padding: 12px 20px;
background: #f8f9fa;
color: #666;
font-size: 14px;
text-align: center;
font-style: italic;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 15px;
}
.statsGrid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.statCard {
padding: 16px;
}
.statValue {
font-size: 24px;
}
.tableHeader, .tableRow {
grid-template-columns: 1.5fr 0.8fr 0.8fr 1fr 1fr 1fr;
gap: 8px;
padding: 12px 15px;
font-size: 13px;
}
.breakdownSection h3 {
font-size: 18px;
}
.container h2 {
font-size: 24px;
}
}
@media (max-width: 480px) {
.statsGrid {
grid-template-columns: 1fr;
}
.tableHeader, .tableRow {
grid-template-columns: 1fr;
gap: 4px;
}
.tableHeader span, .tableRow span, .tableRow .percentageCell {
padding: 4px 0;
}
.tableHeader span:before {
content: attr(data-label) ": ";
font-weight: 700;
display: none;
}
.tableRow span:before {
content: attr(data-label) ": ";
font-weight: 600;
color: #666;
display: inline;
}
.categoryName:before {
content: "";
}
.percentageCell {
gap: 2px;
}
.progressBar {
height: 4px;
}
}

View File

@ -0,0 +1,344 @@
import { useMemo } from 'react'
import { useDatabaseTraceData } from '../hooks/useDatabaseTraceData'
import { processHTTPRequests } from './httprequestviewer/lib/httpRequestProcessor'
import { addRequestPostProcessing } from './httprequestviewer/lib/requestPostProcessor'
import { analyzeCDN, analyzeQueueReason } from './httprequestviewer/lib/analysisUtils'
import { assignConnectionNumbers } from './httprequestviewer/lib/connectionUtils'
import sortRequests from './httprequestviewer/lib/sortRequests'
import type { HTTPRequest } from './httprequestviewer/types/httpRequest'
import styles from './RequestBreakdown.module.css'
interface RequestBreakdownProps {
traceId: string | null
}
interface BreakdownStats {
totalRequests: number
totalSize: number
totalDuration: number
averageResponseTime: number
successRate: number
cacheHitRate: number
}
interface CategoryBreakdown {
name: string
count: number
percentage: number
totalSize: number
averageResponseTime: number
totalResponseTime: number
}
const RequestBreakdown: React.FC<RequestBreakdownProps> = ({ traceId }) => {
const { traceData, loading, error } = useDatabaseTraceData(traceId)
const httpRequests = useMemo(() => {
if (!traceData) return []
const requests = processHTTPRequests(traceData.traceEvents)
const sortedRequests = sortRequests(requests)
const processedRequests = addRequestPostProcessing(sortedRequests, analyzeQueueReason, analyzeCDN)
return assignConnectionNumbers(processedRequests)
}, [traceData])
const overallStats: BreakdownStats = useMemo(() => {
if (httpRequests.length === 0) {
return {
totalRequests: 0,
totalSize: 0,
totalDuration: 0,
averageResponseTime: 0,
successRate: 0,
cacheHitRate: 0
}
}
const totalSize = httpRequests.reduce((sum, req) => {
const size = req.contentLength || req.encodedDataLength || 0
return sum + size
}, 0)
const totalDuration = httpRequests.reduce((sum, req) => {
return sum + (req.timing.duration || 0)
}, 0)
const totalResponseTime = httpRequests.reduce((sum, req) => {
return sum + (req.timing.totalResponseTime || 0)
}, 0)
const successfulRequests = httpRequests.filter(req =>
req.statusCode && req.statusCode >= 200 && req.statusCode < 300
).length
const cachedRequests = httpRequests.filter(req => req.fromCache).length
return {
totalRequests: httpRequests.length,
totalSize,
totalDuration,
averageResponseTime: totalResponseTime / httpRequests.length,
successRate: (successfulRequests / httpRequests.length) * 100,
cacheHitRate: (cachedRequests / httpRequests.length) * 100
}
}, [httpRequests])
const resourceTypeBreakdown: CategoryBreakdown[] = useMemo(() => {
const typeMap = new Map<string, HTTPRequest[]>()
httpRequests.forEach(req => {
const type = req.resourceType || 'unknown'
if (!typeMap.has(type)) {
typeMap.set(type, [])
}
typeMap.get(type)!.push(req)
})
return Array.from(typeMap.entries()).map(([type, requests]) => {
const totalSize = requests.reduce((sum, req) => {
return sum + (req.contentLength || req.encodedDataLength || 0)
}, 0)
const totalResponseTime = requests.reduce((sum, req) => {
return sum + (req.timing.totalResponseTime || 0)
}, 0)
return {
name: type,
count: requests.length,
percentage: (requests.length / httpRequests.length) * 100,
totalSize,
averageResponseTime: totalResponseTime / requests.length,
totalResponseTime
}
}).sort((a, b) => b.count - a.count)
}, [httpRequests])
const statusCodeBreakdown: CategoryBreakdown[] = useMemo(() => {
const statusMap = new Map<string, HTTPRequest[]>()
httpRequests.forEach(req => {
const status = req.statusCode ? Math.floor(req.statusCode / 100) + 'xx' : 'Unknown'
if (!statusMap.has(status)) {
statusMap.set(status, [])
}
statusMap.get(status)!.push(req)
})
return Array.from(statusMap.entries()).map(([status, requests]) => {
const totalSize = requests.reduce((sum, req) => {
return sum + (req.contentLength || req.encodedDataLength || 0)
}, 0)
const totalResponseTime = requests.reduce((sum, req) => {
return sum + (req.timing.totalResponseTime || 0)
}, 0)
return {
name: status,
count: requests.length,
percentage: (requests.length / httpRequests.length) * 100,
totalSize,
averageResponseTime: totalResponseTime / requests.length,
totalResponseTime
}
}).sort((a, b) => b.count - a.count)
}, [httpRequests])
const hostnameBreakdown: CategoryBreakdown[] = useMemo(() => {
const hostMap = new Map<string, HTTPRequest[]>()
httpRequests.forEach(req => {
const hostname = req.hostname || 'unknown'
if (!hostMap.has(hostname)) {
hostMap.set(hostname, [])
}
hostMap.get(hostname)!.push(req)
})
return Array.from(hostMap.entries()).map(([hostname, requests]) => {
const totalSize = requests.reduce((sum, req) => {
return sum + (req.contentLength || req.encodedDataLength || 0)
}, 0)
const totalResponseTime = requests.reduce((sum, req) => {
return sum + (req.timing.totalResponseTime || 0)
}, 0)
return {
name: hostname,
count: requests.length,
percentage: (requests.length / httpRequests.length) * 100,
totalSize,
averageResponseTime: totalResponseTime / requests.length,
totalResponseTime
}
}).sort((a, b) => b.count - a.count)
}, [httpRequests])
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const formatDuration = (microseconds: number): string => {
if (microseconds < 1000) return `${microseconds.toFixed(0)} μs`
if (microseconds < 1000000) return `${(microseconds / 1000).toFixed(1)} ms`
return `${(microseconds / 1000000).toFixed(2)} s`
}
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}>Loading request breakdown...</div>
</div>
)
}
if (error) {
return (
<div className={styles.container}>
<div className={styles.error}>
<h3>Error Loading Data</h3>
<p>{error}</p>
</div>
</div>
)
}
return (
<div className={styles.container}>
<h2>Request Data Breakdown</h2>
{/* Overall Stats */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<h3>Total Requests</h3>
<div className={styles.statValue}>{overallStats.totalRequests}</div>
</div>
<div className={styles.statCard}>
<h3>Total Size</h3>
<div className={styles.statValue}>{formatSize(overallStats.totalSize)}</div>
</div>
<div className={styles.statCard}>
<h3>Average Response Time</h3>
<div className={styles.statValue}>{formatDuration(overallStats.averageResponseTime)}</div>
</div>
<div className={styles.statCard}>
<h3>Success Rate</h3>
<div className={styles.statValue}>{overallStats.successRate.toFixed(1)}%</div>
</div>
<div className={styles.statCard}>
<h3>Cache Hit Rate</h3>
<div className={styles.statValue}>{overallStats.cacheHitRate.toFixed(1)}%</div>
</div>
</div>
{/* Resource Type Breakdown */}
<div className={styles.breakdownSection}>
<h3>By Resource Type</h3>
<div className={styles.breakdownTable}>
<div className={styles.tableHeader}>
<span>Type</span>
<span>Count</span>
<span>Percentage</span>
<span>Total Size</span>
<span>Total Response Time</span>
<span>Avg Response Time</span>
</div>
{resourceTypeBreakdown.map(item => (
<div key={item.name} className={styles.tableRow}>
<span className={styles.categoryName}>{item.name}</span>
<span>{item.count}</span>
<div className={styles.percentageCell}>
<span>{item.percentage.toFixed(1)}%</span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
<span>{formatSize(item.totalSize)}</span>
<span>{formatDuration(item.totalResponseTime)}</span>
<span>{formatDuration(item.averageResponseTime)}</span>
</div>
))}
</div>
</div>
{/* Status Code Breakdown */}
<div className={styles.breakdownSection}>
<h3>By Status Code</h3>
<div className={styles.breakdownTable}>
<div className={styles.tableHeader}>
<span>Status</span>
<span>Count</span>
<span>Percentage</span>
<span>Total Size</span>
<span>Total Response Time</span>
<span>Avg Response Time</span>
</div>
{statusCodeBreakdown.map(item => (
<div key={item.name} className={styles.tableRow}>
<span className={styles.categoryName}>{item.name}</span>
<span>{item.count}</span>
<div className={styles.percentageCell}>
<span>{item.percentage.toFixed(1)}%</span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
<span>{formatSize(item.totalSize)}</span>
<span>{formatDuration(item.totalResponseTime)}</span>
<span>{formatDuration(item.averageResponseTime)}</span>
</div>
))}
</div>
</div>
{/* Hostname Breakdown */}
<div className={styles.breakdownSection}>
<h3>By Hostname</h3>
<div className={styles.breakdownTable}>
<div className={styles.tableHeader}>
<span>Hostname</span>
<span>Count</span>
<span>Percentage</span>
<span>Total Size</span>
<span>Total Response Time</span>
<span>Avg Response Time</span>
</div>
{hostnameBreakdown.slice(0, 10).map(item => (
<div key={item.name} className={styles.tableRow}>
<span className={styles.categoryName}>{item.name}</span>
<span>{item.count}</span>
<div className={styles.percentageCell}>
<span>{item.percentage.toFixed(1)}%</span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
<span>{formatSize(item.totalSize)}</span>
<span>{formatDuration(item.totalResponseTime)}</span>
<span>{formatDuration(item.averageResponseTime)}</span>
</div>
))}
</div>
{hostnameBreakdown.length > 10 && (
<div className={styles.moreInfo}>
Showing top 10 of {hostnameBreakdown.length} hostnames
</div>
)}
</div>
</div>
)
}
export default RequestBreakdown