Compare commits

..

No commits in common. "b5b7aafca59c3483df5decd1e74b60759dd8a242" and "2ee9c3fc284930c56121c9c397c13c7af0d026e6" have entirely different histories.

46 changed files with 398 additions and 249616 deletions

View File

@ -73,4 +73,4 @@ This is a React + TypeScript + Vite project called "perfviz" - a modern web appl
- **React Integration**: Proper cleanup with engine disposal in useEffect return function - **React Integration**: Proper cleanup with engine disposal in useEffect return function
- **Version-Specific Documentation**: ALWAYS check Babylon.js documentation for version 8.21.1 specifically to avoid deprecated methods and ensure current API usage - **Version-Specific Documentation**: ALWAYS check Babylon.js documentation for version 8.21.1 specifically to avoid deprecated methods and ensure current API usage
- **API Verification**: Before suggesting any Babylon.js code, verify method signatures and availability in the current version - **API Verification**: Before suggesting any Babylon.js code, verify method signatures and availability in the current version
- **CSS**: Use CSS modules for styling components, avoid inline styles for better performance and maintainability -

File diff suppressed because one or more lines are too long

View File

@ -49,105 +49,10 @@
--radius-xxl: 12px; --radius-xxl: 12px;
/* Z-index */ /* Z-index */
--z-modal: 10000; --z-modal: 1000;
--z-tooltip: 50000;
} }
body { body {
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-family: system-ui, var(--font-family-base); font-family: system-ui, var(--font-family-base);
} }
/* Main App Layout */
.mainApp {
background-color: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
}
.appHeader {
padding: var(--spacing-lg) var(--spacing-lg);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.headerLeft {
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.backButton {
background: var(--color-bg-light);
border: 1px solid var(--color-border);
color: var(--color-text);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-size-base);
transition: all 0.2s ease;
}
.backButton:hover {
background: var(--color-bg-hover);
border-color: var(--color-primary);
color: var(--color-primary);
}
.appTitle {
margin: 0;
color: var(--color-text-highlight);
font-size: var(--font-size-xxl);
font-weight: bold;
}
/* Navigation */
.nav {
display: flex;
gap: var(--spacing-xs);
}
.navButton {
background: var(--color-bg-secondary);
border: 2px solid var(--color-border);
color: var(--color-text);
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-size-lg);
font-weight: 500;
transition: all 0.3s ease;
position: relative;
min-width: 120px;
}
.navButton:hover {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 123, 255, 0.3);
}
.navButton.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
box-shadow: 0 2px 4px rgba(0, 123, 255, 0.2);
}
.navButton.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid var(--color-primary);
}

View File

@ -3,15 +3,13 @@ import styles from './App.module.css'
import TraceViewer from './components/TraceViewer' 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 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' | 'breakdown' | 'debug' type AppView = 'trace' | 'phases' | 'http' | 'debug'
type AppMode = 'selector' | 'upload' | 'analysis' type AppMode = 'selector' | 'upload' | 'analysis'
type ThreeDView = 'network' | 'timeline' | null type ThreeDView = 'network' | 'timeline' | null
@ -35,7 +33,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', 'breakdown', 'debug'] const validViews: AppView[] = ['trace', 'phases', 'http', '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
@ -44,7 +42,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', 'breakdown', 'debug'] const validViews: AppView[] = ['trace', 'phases', 'http', '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) {
@ -183,19 +181,18 @@ function App() {
return ( return (
<> <>
<div className={styles.mainApp}> <div className={styles.mainApp}>
<div className={styles.appHeader}> <div>
<div className={styles.headerLeft}> <div>
<button <button
className={styles.backButton}
onClick={handleBackToSelector} onClick={handleBackToSelector}
> >
Back to Traces Back to Traces
</button> </button>
<h1 className={styles.appTitle}>Performance Trace Analysis</h1> <h1>Perf Viz</h1>
</div> </div>
<nav className={styles.nav}>
<nav>
<button <button
className={`${styles.navButton} ${currentView === 'trace' ? styles.active : ''}`}
onClick={() => { onClick={() => {
setCurrentView('trace') setCurrentView('trace')
updateUrlWithTraceId(selectedTraceId, 'trace', null) updateUrlWithTraceId(selectedTraceId, 'trace', null)
@ -204,7 +201,6 @@ function App() {
Trace Stats Trace Stats
</button> </button>
<button <button
className={`${styles.navButton} ${currentView === 'phases' ? styles.active : ''}`}
onClick={() => { onClick={() => {
setCurrentView('phases') setCurrentView('phases')
updateUrlWithTraceId(selectedTraceId, 'phases', null) updateUrlWithTraceId(selectedTraceId, 'phases', null)
@ -213,7 +209,6 @@ function App() {
Phase Events Phase Events
</button> </button>
<button <button
className={`${styles.navButton} ${currentView === 'http' ? styles.active : ''}`}
onClick={() => { onClick={() => {
setCurrentView('http') setCurrentView('http')
updateUrlWithTraceId(selectedTraceId, 'http', null) updateUrlWithTraceId(selectedTraceId, 'http', null)
@ -222,25 +217,6 @@ function App() {
HTTP Requests HTTP Requests
</button> </button>
<button <button
className={`${styles.navButton} ${currentView === 'js' ? styles.active : ''}`}
onClick={() => {
setCurrentView('js')
updateUrlWithTraceId(selectedTraceId, 'js', null)
}}
>
JavaScript
</button>
<button
className={`${styles.navButton} ${currentView === 'breakdown' ? styles.active : ''}`}
onClick={() => {
setCurrentView('breakdown')
updateUrlWithTraceId(selectedTraceId, 'breakdown', null)
}}
>
Request Breakdown
</button>
<button
className={`${styles.navButton} ${currentView === 'debug' ? styles.active : ''}`}
onClick={() => { onClick={() => {
setCurrentView('debug') setCurrentView('debug')
updateUrlWithTraceId(selectedTraceId, 'debug', null) updateUrlWithTraceId(selectedTraceId, 'debug', null)
@ -263,14 +239,6 @@ function App() {
<HTTPRequestViewer traceId={selectedTraceId} /> <HTTPRequestViewer traceId={selectedTraceId} />
)} )}
{currentView === 'js' && (
<JavaScriptViewer traceId={selectedTraceId} />
)}
{currentView === 'breakdown' && (
<RequestBreakdown traceId={selectedTraceId} />
)}
{currentView === 'debug' && traceData && ( {currentView === 'debug' && traceData && (
<RequestDebugger traceEvents={traceData.traceEvents} /> <RequestDebugger traceEvents={traceData.traceEvents} />
)} )}

View File

@ -1,123 +0,0 @@
import React from 'react'
import styles from './RequestBreakdown.module.css'
export type SortColumn = 'name' | 'count' | 'percentage' | 'totalSize' | 'sizePercentage' | 'totalResponseTime' | 'responseTimePercentage' | 'averageResponseTime'
export type SortDirection = 'asc' | 'desc'
interface BreakdownTableHeaderProps {
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()}`
}
return (
<div className={styles.tableHeader}>
<button
className={styles.sortButton}
onClick={() => onSort('name')}
title={getTooltipText('name', categoryLabel)}
>
<span className={styles.sortButtonContent}>
{categoryLabel}
{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>
)
}
export default BreakdownTableHeader

View File

@ -1,219 +0,0 @@
import React, { useMemo, useState } from 'react'
import type { HTTPRequest } from '../httprequestviewer/types/httpRequest'
import BreakdownTableHeader, { type SortColumn, type SortDirection } from './BreakdownTableHeader'
import styles from './RequestBreakdown.module.css'
interface CategoryBreakdown {
name: string
count: number
percentage: number
totalSize: number
sizePercentage: number
totalResponseTime: number
responseTimePercentage: number
averageResponseTime: number
}
interface HostnameBreakdownProps {
httpRequests: HTTPRequest[]
}
const HostnameBreakdown: React.FC<HostnameBreakdownProps> = ({ httpRequests }) => {
const [showAllHostnames, setShowAllHostnames] = useState(false)
const [sortColumn, setSortColumn] = useState<SortColumn | null>('count')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
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`
}
const baseHostnameBreakdown: 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)
})
// Calculate total size and response time across all requests for percentage calculations
const totalAllSize = httpRequests.reduce((sum, req) => {
return sum + (req.contentLength || req.encodedDataLength || 0)
}, 0)
const totalAllResponseTime = httpRequests.reduce((sum, req) => {
return sum + (req.timing.totalResponseTime || 0)
}, 0)
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,
sizePercentage: totalAllSize > 0 ? (totalSize / totalAllSize) * 100 : 0,
totalResponseTime,
responseTimePercentage: totalAllResponseTime > 0 ? (totalResponseTime / totalAllResponseTime) * 100 : 0,
averageResponseTime: totalResponseTime / requests.length
}
})
}, [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 (
<div className={styles.breakdownSection}>
<h3>By Hostname</h3>
<div className={styles.breakdownTable}>
<BreakdownTableHeader
categoryLabel="Hostname"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
{(showAllHostnames ? sortedHostnameBreakdown : sortedHostnameBreakdown.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>
<div className={styles.percentageCell}>
<span>{item.sizePercentage.toFixed(1)}%</span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${item.sizePercentage}%` }}
></div>
</div>
</div>
<span>{formatDuration(item.totalResponseTime)}</span>
<div className={styles.percentageCell}>
<span>{item.responseTimePercentage.toFixed(1)}%</span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${item.responseTimePercentage}%` }}
></div>
</div>
</div>
<span>{formatDuration(item.averageResponseTime)}</span>
</div>
))}
</div>
{sortedHostnameBreakdown.length > 10 && (
<div className={styles.moreInfo}>
{showAllHostnames ? (
<>
Showing all {sortedHostnameBreakdown.length} hostnames
<button
onClick={() => setShowAllHostnames(false)}
className={styles.toggleButton}
>
Show Top 10 Only
</button>
</>
) : (
<>
Showing top 10 of {sortedHostnameBreakdown.length} hostnames
<button
onClick={() => setShowAllHostnames(true)}
className={styles.toggleButton}
>
Show All Hostnames
</button>
</>
)}
</div>
)}
</div>
)
}
export default HostnameBreakdown

View File

@ -1,334 +0,0 @@
.container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.container h2 {
margin-bottom: 30px;
color: white;
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: white;
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);
width: 100%;
table-layout: fixed;
}
.tableHeader {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr 1fr 1.5fr 1fr 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;
}
.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 {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr 1fr 1.5fr 1fr 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;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
line-height: 1.3;
}
.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(135deg, #667eea 0%, #764ba2 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;
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
}
.toggleButton {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-style: normal;
}
.toggleButton:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
/* 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 0.8fr 1fr 0.8fr 1fr;
gap: 8px;
padding: 12px 15px;
font-size: 13px;
}
.categoryName {
font-size: 12px;
line-height: 1.3;
}
.breakdownSection h3 {
font-size: 18px;
color: white;
}
.container h2 {
font-size: 24px;
color: white;
}
}
@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;
}
.moreInfo {
flex-direction: column;
gap: 10px;
}
.toggleButton {
padding: 8px 16px;
font-size: 13px;
}
}

View File

@ -1,57 +0,0 @@
import React from 'react'
import styles from './RequestBreakdown.module.css'
interface RequestDataSummaryProps {
totalRequests: number
totalSize: number
averageResponseTime: number
successRate: number
cacheHitRate: number
}
const RequestDataSummary: React.FC<RequestDataSummaryProps> = ({
totalRequests,
totalSize,
averageResponseTime,
successRate,
cacheHitRate
}) => {
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`
}
return (
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<h3>Total Requests</h3>
<div className={styles.statValue}>{totalRequests}</div>
</div>
<div className={styles.statCard}>
<h3>Total Size</h3>
<div className={styles.statValue}>{formatSize(totalSize)}</div>
</div>
<div className={styles.statCard}>
<h3>Average Response Time</h3>
<div className={styles.statValue}>{formatDuration(averageResponseTime)}</div>
</div>
<div className={styles.statCard}>
<h3>Success Rate</h3>
<div className={styles.statValue}>{successRate.toFixed(1)}%</div>
</div>
<div className={styles.statCard}>
<h3>Cache Hit Rate</h3>
<div className={styles.statValue}>{cacheHitRate.toFixed(1)}%</div>
</div>
</div>
)
}
export default RequestDataSummary

View File

@ -1,192 +0,0 @@
import React, { useMemo, useState } from 'react'
import type { HTTPRequest } from '../httprequestviewer/types/httpRequest'
import BreakdownTableHeader, { type SortColumn, type SortDirection } from './BreakdownTableHeader'
import styles from './RequestBreakdown.module.css'
interface CategoryBreakdown {
name: string
count: number
percentage: number
totalSize: number
sizePercentage: number
totalResponseTime: number
responseTimePercentage: number
averageResponseTime: number
}
interface ResourceTypeBreakdownProps {
httpRequests: HTTPRequest[]
}
const ResourceTypeBreakdown: React.FC<ResourceTypeBreakdownProps> = ({ httpRequests }) => {
const [sortColumn, setSortColumn] = useState<SortColumn | null>('count')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
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`
}
const baseResourceTypeBreakdown: 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)
})
// Calculate total size and response time across all requests for percentage calculations
const totalAllSize = httpRequests.reduce((sum, req) => {
return sum + (req.contentLength || req.encodedDataLength || 0)
}, 0)
const totalAllResponseTime = httpRequests.reduce((sum, req) => {
return sum + (req.timing.totalResponseTime || 0)
}, 0)
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,
sizePercentage: totalAllSize > 0 ? (totalSize / totalAllSize) * 100 : 0,
totalResponseTime,
responseTimePercentage: totalAllResponseTime > 0 ? (totalResponseTime / totalAllResponseTime) * 100 : 0,
averageResponseTime: totalResponseTime / requests.length
}
})
}, [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 (
<div className={styles.breakdownSection}>
<h3>By Resource Type</h3>
<div className={styles.breakdownTable}>
<BreakdownTableHeader
categoryLabel="Resource Type"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
{sortedResourceTypeBreakdown.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>
<div className={styles.percentageCell}>
<span>{item.sizePercentage.toFixed(1)}%</span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${item.sizePercentage}%` }}
></div>
</div>
</div>
<span>{formatDuration(item.totalResponseTime)}</span>
<div className={styles.percentageCell}>
<span>{item.responseTimePercentage.toFixed(1)}%</span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${item.responseTimePercentage}%` }}
></div>
</div>
</div>
<span>{formatDuration(item.averageResponseTime)}</span>
</div>
))}
</div>
</div>
)
}
export default ResourceTypeBreakdown

View File

@ -1,192 +0,0 @@
import React, { useMemo, useState } from 'react'
import type { HTTPRequest } from '../httprequestviewer/types/httpRequest'
import BreakdownTableHeader, { type SortColumn, type SortDirection } from './BreakdownTableHeader'
import styles from './RequestBreakdown.module.css'
interface CategoryBreakdown {
name: string
count: number
percentage: number
totalSize: number
sizePercentage: number
totalResponseTime: number
responseTimePercentage: number
averageResponseTime: number
}
interface StatusCodeBreakdownProps {
httpRequests: HTTPRequest[]
}
const StatusCodeBreakdown: React.FC<StatusCodeBreakdownProps> = ({ httpRequests }) => {
const [sortColumn, setSortColumn] = useState<SortColumn | null>('count')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
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`
}
const baseStatusCodeBreakdown: 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)
})
// Calculate total size and response time across all requests for percentage calculations
const totalAllSize = httpRequests.reduce((sum, req) => {
return sum + (req.contentLength || req.encodedDataLength || 0)
}, 0)
const totalAllResponseTime = httpRequests.reduce((sum, req) => {
return sum + (req.timing.totalResponseTime || 0)
}, 0)
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,
sizePercentage: totalAllSize > 0 ? (totalSize / totalAllSize) * 100 : 0,
totalResponseTime,
responseTimePercentage: totalAllResponseTime > 0 ? (totalResponseTime / totalAllResponseTime) * 100 : 0,
averageResponseTime: totalResponseTime / requests.length
}
})
}, [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 (
<div className={styles.breakdownSection}>
<h3>By Status Code</h3>
<div className={styles.breakdownTable}>
<BreakdownTableHeader
categoryLabel="Status Code"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
{sortedStatusCodeBreakdown.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>
<div className={styles.percentageCell}>
<span>{item.sizePercentage.toFixed(1)}%</span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${item.sizePercentage}%` }}
></div>
</div>
</div>
<span>{formatDuration(item.totalResponseTime)}</span>
<div className={styles.percentageCell}>
<span>{item.responseTimePercentage.toFixed(1)}%</span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${item.responseTimePercentage}%` }}
></div>
</div>
</div>
<span>{formatDuration(item.averageResponseTime)}</span>
</div>
))}
</div>
</div>
)
}
export default StatusCodeBreakdown

View File

@ -1,120 +0,0 @@
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 RequestDataSummary from './RequestDataSummary'
import ResourceTypeBreakdown from './ResourceTypeBreakdown'
import StatusCodeBreakdown from './StatusCodeBreakdown'
import HostnameBreakdown from './HostnameBreakdown'
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
}
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])
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 */}
<RequestDataSummary
totalRequests={overallStats.totalRequests}
totalSize={overallStats.totalSize}
averageResponseTime={overallStats.averageResponseTime}
successRate={overallStats.successRate}
cacheHitRate={overallStats.cacheHitRate}
/>
<ResourceTypeBreakdown httpRequests={httpRequests} />
<StatusCodeBreakdown httpRequests={httpRequests} />
<HostnameBreakdown httpRequests={httpRequests} />
</div>
)
}
export default RequestBreakdown

View File

@ -1,125 +0,0 @@
/* Column Settings component styles using CSS variables from App.module.css */
.columnSettings {
margin-bottom: var(--spacing-md);
}
.toggleButton {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--color-text);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-size-base);
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.toggleButton:hover {
background: var(--color-bg-hover);
border-color: var(--color-primary);
color: var(--color-primary);
}
.panel {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-lg);
margin-top: var(--spacing-sm);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.panelHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.panelHeader h3 {
margin: 0;
color: var(--color-text-highlight);
font-size: var(--font-size-lg);
font-weight: bold;
}
.bulkActions {
display: flex;
gap: var(--spacing-xs);
}
.bulkButton {
background: var(--color-bg-light);
border: 1px solid var(--color-border);
color: var(--color-text);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-sm);
transition: all 0.2s ease;
}
.bulkButton:hover {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.columnGroups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
}
.columnGroup {
background: var(--color-bg-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
}
.groupTitle {
margin: 0 0 var(--spacing-md) 0;
color: var(--color-text-highlight);
font-size: var(--font-size-md);
font-weight: bold;
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-xs);
}
.columnList {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.columnItem {
display: flex;
align-items: center;
gap: var(--spacing-xs);
cursor: pointer;
padding: var(--spacing-xs);
border-radius: var(--radius-sm);
transition: background-color 0.2s ease;
}
.columnItem:hover {
background-color: var(--color-bg-hover);
}
.checkbox {
accent-color: var(--color-primary);
cursor: pointer;
}
.columnLabel {
font-size: var(--font-size-base);
color: var(--color-text);
cursor: pointer;
user-select: none;
}

View File

@ -1,122 +0,0 @@
import React from 'react'
import styles from './ColumnSettings.module.css'
interface ColumnConfig {
key: string
label: string
group: string
}
const COLUMN_CONFIGS: ColumnConfig[] = [
// Basic Info
{ key: 'expand', label: 'Expand', group: 'Basic' },
{ key: 'method', label: 'Method', group: 'Basic' },
{ key: 'status', label: 'Status', group: 'Basic' },
{ key: 'type', label: 'Type', group: 'Basic' },
{ key: 'priority', label: 'Priority', group: 'Basic' },
{ key: 'url', label: 'URL', group: 'Basic' },
// Connection Info
{ key: 'connectionNumber', label: 'Connection #', group: 'Connection' },
{ key: 'requestNumber', label: 'Request #', group: 'Connection' },
// Timing
{ key: 'startTime', label: 'Start Time', group: 'Timing' },
{ key: 'queueTime', label: 'Queue Time', group: 'Timing' },
{ key: 'dns', label: 'DNS', group: 'Timing' },
{ key: 'connection', label: 'Connection', group: 'Timing' },
{ key: 'serverLatency', label: 'Server Latency', group: 'Timing' },
{ key: 'duration', label: 'Duration', group: 'Timing' },
{ key: 'totalResponseTime', label: 'Total Response Time', group: 'Timing' },
// Size & Performance
{ key: 'dataRate', label: 'Data Rate', group: 'Performance' },
{ key: 'size', label: 'Size', group: 'Performance' },
{ key: 'contentLength', label: 'Content-Length', group: 'Performance' },
// Advanced
{ key: 'protocol', label: 'Protocol', group: 'Advanced' },
{ key: 'cdn', label: 'CDN', group: 'Advanced' },
{ key: 'cache', label: 'Cache', group: 'Advanced' }
]
interface ColumnSettingsProps {
visibleColumns: Record<string, boolean>
onColumnToggle: (column: string) => void
isOpen: boolean
onToggle: () => void
onShowAll: () => void
onHideAll: () => void
onResetDefaults: () => void
}
const ColumnSettings: React.FC<ColumnSettingsProps> = ({
visibleColumns,
onColumnToggle,
isOpen,
onToggle,
onShowAll,
onHideAll,
onResetDefaults
}) => {
const groups = [...new Set(COLUMN_CONFIGS.map(col => col.group))]
return (
<div className={styles.columnSettings}>
<button
className={styles.toggleButton}
onClick={onToggle}
title="Column Settings"
>
Columns {isOpen ? '▼' : '▶'}
</button>
{isOpen && (
<div className={styles.panel}>
<div className={styles.panelHeader}>
<h3>Column Visibility</h3>
<div className={styles.bulkActions}>
<button onClick={onShowAll} className={styles.bulkButton}>
Show All
</button>
<button onClick={onHideAll} className={styles.bulkButton}>
Hide All
</button>
<button onClick={onResetDefaults} className={styles.bulkButton}>
Reset Defaults
</button>
</div>
</div>
<div className={styles.columnGroups}>
{groups.map(group => (
<div key={group} className={styles.columnGroup}>
<h4 className={styles.groupTitle}>{group}</h4>
<div className={styles.columnList}>
{COLUMN_CONFIGS
.filter(col => col.group === group)
.map(column => (
<label key={column.key} className={styles.columnItem}>
<input
type="checkbox"
checked={visibleColumns[column.key] || false}
onChange={() => onColumnToggle(column.key)}
className={styles.checkbox}
/>
<span className={styles.columnLabel}>
{column.label}
</span>
</label>
))
}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
export default ColumnSettings

View File

@ -1,129 +0,0 @@
.container {
padding: 20px;
}
.errorContainer {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.errorMessage {
background: #fee;
border: 1px solid #fcc;
padding: 20px;
border-radius: 8px;
text-align: center;
}
.errorMessage h3 {
color: #c33;
margin-bottom: 10px;
}
.exportControls {
display: flex;
justify-content: flex-end;
margin: 15px 0;
padding: 10px 0;
border-bottom: 1px solid #e0e0e0;
}
.downloadButton {
background: #4CAF50;
color: white;
border: none;
padding: 10px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s ease;
display: flex;
align-items: center;
gap: 6px;
}
.downloadButton:hover:not(:disabled) {
background: #45a049;
}
.downloadButton:disabled {
background: #cccccc;
cursor: not-allowed;
}
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modalContainer {
background: white;
border-radius: 12px;
width: 95vw;
height: 95vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
background: #f8f9fa;
}
.modalTitle {
margin: 0;
color: #333;
font-size: 18px;
}
.modalCloseButton {
background: #dc3545;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.modalCloseButton:hover {
background: #c82333;
}
.modalContent {
flex: 1;
padding: 0;
overflow: hidden;
}
.modalLegend {
background: #f8f9fa;
border-top: 1px solid #e0e0e0;
padding: 15px 20px;
font-size: 13px;
line-height: 1.5;
max-height: 200px;
overflow-y: auto;
}
.modalLegend div {
margin-bottom: 4px;
}

View File

@ -3,7 +3,6 @@ import { useDatabaseTraceData } from '../../hooks/useDatabaseTraceData'
import { getUrlParams, updateUrlWithTraceId } from '../../App' import { getUrlParams, updateUrlWithTraceId } from '../../App'
import RequestFilters from './RequestFilters' import RequestFilters from './RequestFilters'
import RequestsTable from './RequestsTable' import RequestsTable from './RequestsTable'
import ColumnSettings from './ColumnSettings'
import styles from './HTTPRequestViewer.module.css' import styles from './HTTPRequestViewer.module.css'
// Lazy load 3D viewers to reduce main bundle size // Lazy load 3D viewers to reduce main bundle size
@ -51,14 +50,12 @@ import { extractScreenshots, findUniqueScreenshots } from './lib/screenshotUtils
import { processHTTPRequests } from './lib/httpRequestProcessor' import { processHTTPRequests } from './lib/httpRequestProcessor'
import { analyzeCDN, analyzeQueueReason } from './lib/analysisUtils' import { analyzeCDN, analyzeQueueReason } from './lib/analysisUtils'
import { addRequestPostProcessing } from './lib/requestPostProcessor' import { addRequestPostProcessing } from './lib/requestPostProcessor'
import { assignConnectionNumbers } from './lib/connectionUtils'
// Import types // Import types
import type { HTTPRequest, ScreenshotEvent } from './types/httpRequest' import type { HTTPRequest, ScreenshotEvent } from './types/httpRequest'
import HTTPRequestLoading from "./HTTPRequestLoading.tsx"; import HTTPRequestLoading from "./HTTPRequestLoading.tsx";
import sortRequests from "./lib/sortRequests.ts"; import sortRequests from "./lib/sortRequests.ts";
import PaginationControls from "./PaginationControls.tsx"; import PaginationControls from "./PaginationControls.tsx";
import { exportRequestsToCSV, downloadCSV } from './lib/csvExport';
interface HTTPRequestViewerProps { interface HTTPRequestViewerProps {
@ -79,43 +76,6 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
const [show3DViewer, setShow3DViewer] = useState(false) const [show3DViewer, setShow3DViewer] = useState(false)
const [showTimelineViewer, setShowTimelineViewer] = useState(false) const [showTimelineViewer, setShowTimelineViewer] = useState(false)
// Column visibility state - default visible columns as requested
const [visibleColumns, setVisibleColumns] = useState(() => {
const saved = localStorage.getItem('httpRequestTableColumns')
if (saved) {
try {
return JSON.parse(saved)
} catch (e) {
console.warn('Failed to parse saved column settings, using defaults')
}
}
// Default visible columns
return {
expand: true,
method: false,
status: false,
type: false,
priority: false,
url: true,
connectionNumber: false,
requestNumber: false,
startTime: true,
queueTime: false,
dns: false,
connection: false,
serverLatency: false,
duration: false,
totalResponseTime: true,
dataRate: true,
size: false,
contentLength: true,
protocol: false,
cdn: false,
cache: false
}
})
const [columnSettingsOpen, setColumnSettingsOpen] = useState(false)
// Initialize 3D view state from URL on component mount and handle URL changes // Initialize 3D view state from URL on component mount and handle URL changes
useEffect(() => { useEffect(() => {
const updateFrom3DUrl = () => { const updateFrom3DUrl = () => {
@ -156,69 +116,12 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
setSsimThreshold(pendingSSIMThreshold) setSsimThreshold(pendingSSIMThreshold)
} }
// Column visibility handlers
const handleColumnToggle = (column: string) => {
const newVisibleColumns = {
...visibleColumns,
[column]: !visibleColumns[column]
}
setVisibleColumns(newVisibleColumns)
localStorage.setItem('httpRequestTableColumns', JSON.stringify(newVisibleColumns))
}
const handleShowAllColumns = () => {
const allVisible = Object.keys(visibleColumns).reduce((acc, key) => {
acc[key] = true
return acc
}, {} as Record<string, boolean>)
setVisibleColumns(allVisible)
localStorage.setItem('httpRequestTableColumns', JSON.stringify(allVisible))
}
const handleHideAllColumns = () => {
const allHidden = Object.keys(visibleColumns).reduce((acc, key) => {
acc[key] = key === 'expand' // Always keep expand column visible
return acc
}, {} as Record<string, boolean>)
setVisibleColumns(allHidden)
localStorage.setItem('httpRequestTableColumns', JSON.stringify(allHidden))
}
const handleResetDefaults = () => {
const defaultColumns = {
expand: true,
method: false,
status: false,
type: false,
priority: false,
url: true,
connectionNumber: false,
requestNumber: false,
startTime: true,
queueTime: false,
dns: false,
connection: false,
serverLatency: false,
duration: false,
totalResponseTime: true,
dataRate: true,
size: false,
contentLength: true,
protocol: false,
cdn: false,
cache: false
}
setVisibleColumns(defaultColumns)
localStorage.setItem('httpRequestTableColumns', JSON.stringify(defaultColumns))
}
const httpRequests = useMemo(() => { const httpRequests = useMemo(() => {
if (!traceData) return [] if (!traceData) return []
const httpRequests = processHTTPRequests(traceData.traceEvents) const httpRequests = processHTTPRequests(traceData.traceEvents)
const sortedRequests = sortRequests(httpRequests) const sortedRequests = sortRequests(httpRequests)
const processedRequests = addRequestPostProcessing(sortedRequests, analyzeQueueReason, analyzeCDN) const processedRequests = addRequestPostProcessing(sortedRequests, analyzeQueueReason, analyzeCDN)
const requestsWithConnections = assignConnectionNumbers(processedRequests) return processedRequests
return requestsWithConnections
}, [traceData]) }, [traceData])
// Extract and process screenshots with SSIM analysis // Extract and process screenshots with SSIM analysis
@ -383,15 +286,6 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
setExpandedRows(newExpanded) setExpandedRows(newExpanded)
} }
const handleDownloadCSV = () => {
const csvContent = exportRequestsToCSV(filteredRequests, {
includeHeaders: true
})
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
const filename = `http-requests-${timestamp}.csv`
downloadCSV(csvContent, filename)
}
if (loading) { if (loading) {
return ( return (
@ -449,17 +343,6 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
handleSSIMRecalculate={handleSSIMRecalculate} handleSSIMRecalculate={handleSSIMRecalculate}
/> />
{/* Export Controls */}
<div className={styles.exportControls}>
<button
onClick={handleDownloadCSV}
className={styles.downloadButton}
disabled={filteredRequests.length === 0}
>
📥 Download CSV ({filteredRequests.length} requests)
</button>
</div>
<PaginationControls currentPage={currentPage} setCurrentPage={setCurrentPage} totalPages={totalPages} /> <PaginationControls currentPage={currentPage} setCurrentPage={setCurrentPage} totalPages={totalPages} />
{/* 3D Network Visualization Modal */} {/* 3D Network Visualization Modal */}
@ -544,17 +427,6 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
</div> </div>
)} )}
{/* Column Settings */}
<ColumnSettings
visibleColumns={visibleColumns}
onColumnToggle={handleColumnToggle}
isOpen={columnSettingsOpen}
onToggle={() => setColumnSettingsOpen(!columnSettingsOpen)}
onShowAll={handleShowAllColumns}
onHideAll={handleHideAllColumns}
onResetDefaults={handleResetDefaults}
/>
{/* Requests Table */} {/* Requests Table */}
<RequestsTable <RequestsTable
httpRequests={httpRequests} httpRequests={httpRequests}
@ -564,7 +436,6 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
showQueueAnalysis={showQueueAnalysis} showQueueAnalysis={showQueueAnalysis}
expandedRows={expandedRows} expandedRows={expandedRows}
onToggleRowExpansion={toggleRowExpansion} onToggleRowExpansion={toggleRowExpansion}
visibleColumns={visibleColumns}
/> />
</div> </div>
) )

View File

@ -1,211 +0,0 @@
/* Initiator Chain styles using CSS variables from App.module.css */
.chainContainer {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-top: var(--spacing-md);
}
.chainHeader {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--color-primary);
}
.chainTitle {
color: var(--color-text-highlight);
font-size: var(--font-size-lg);
font-weight: bold;
margin: 0 0 var(--spacing-xs) 0;
}
.chainDescription {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin: 0;
}
.noChain {
color: var(--color-text-muted);
font-style: italic;
text-align: center;
padding: var(--spacing-lg);
background: var(--color-bg-light);
border-radius: var(--radius-md);
}
/* Chain tree structure */
.chainTree {
position: relative;
}
.chainNodeContainer {
position: relative;
}
.chainNode {
position: relative;
display: flex;
align-items: center;
margin-bottom: var(--spacing-sm);
transition: all 0.2s ease;
}
.chainNode.selected .nodeContent {
background: var(--color-primary);
color: white;
box-shadow: 0 2px 8px rgba(var(--color-primary-rgb), 0.3);
}
.chainNode.selected .nodeUrl,
.chainNode.selected .nodeInfo {
color: white;
}
.chainNode.selected .resourceType,
.chainNode.selected .initiatorType {
color: rgba(255, 255, 255, 0.9);
}
/* Connection lines */
.nodeConnector {
position: relative;
width: 20px;
height: 24px;
flex-shrink: 0;
}
.verticalLine {
position: absolute;
left: -10px;
top: -12px;
width: 1px;
height: 24px;
background: var(--color-border);
}
.horizontalLine {
position: absolute;
left: -10px;
top: 12px;
width: 20px;
height: 1px;
background: var(--color-border);
}
/* Node content */
.nodeContent {
display: flex;
align-items: center;
gap: var(--spacing-sm);
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-sm);
flex: 1;
transition: all 0.2s ease;
}
.nodeContent:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.nodeIcon {
font-size: var(--font-size-lg);
flex-shrink: 0;
}
.nodeDetails {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.nodeUrl {
font-size: var(--font-size-base);
font-weight: 500;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nodeInfo {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--font-size-xs);
}
.resourceType {
color: var(--color-primary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.initiatorType {
color: var(--color-text-muted);
font-style: italic;
}
.selectedIndicator {
background: rgba(255, 255, 255, 0.2);
color: white;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
/* Responsive design */
@media (max-width: 768px) {
.chainContainer {
padding: var(--spacing-md);
}
.chainNode {
margin-left: 0 !important;
}
.nodeConnector {
display: none;
}
.nodeContent {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.nodeInfo {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.selectedIndicator {
align-self: flex-end;
}
}
@media (max-width: 480px) {
.chainHeader {
margin-bottom: var(--spacing-md);
}
.nodeDetails {
gap: 2px;
}
.nodeUrl {
font-size: var(--font-size-sm);
}
}

View File

@ -1,189 +0,0 @@
import React, { useMemo } from 'react'
import type { HTTPRequest } from './types/httpRequest'
import styles from './InitiatorChain.module.css'
interface InitiatorChainProps {
requests: HTTPRequest[]
selectedRequest: HTTPRequest
}
interface ChainNode {
request: HTTPRequest
level: number
children: ChainNode[]
isSelected: boolean
}
const InitiatorChain: React.FC<InitiatorChainProps> = ({ requests, selectedRequest }) => {
// Build the initiator chain tree
const chainTree = useMemo(() => {
const buildChain = (request: HTTPRequest, level: number = 0, visited: Set<string> = new Set()): ChainNode => {
// Prevent infinite loops
if (visited.has(request.requestId)) {
return {
request,
level,
children: [],
isSelected: request.requestId === selectedRequest.requestId
}
}
visited.add(request.requestId)
// Find requests initiated by this request
const children = requests
.filter(r => {
if (!r.initiator || r.requestId === request.requestId) return false
// Check if this request was initiated by the current request
if (r.initiator.type === 'script' && r.initiator.stack) {
// Look for the current request's URL in the call stack
return r.initiator.stack.callFrames.some(frame =>
frame.url === request.url ||
frame.url.includes(new URL(request.url).pathname)
)
}
if (r.initiator.type === 'parser' && r.initiator.url) {
return r.initiator.url === request.url
}
return false
})
.map(r => buildChain(r, level + 1, new Set(visited)))
return {
request,
level,
children,
isSelected: request.requestId === selectedRequest.requestId
}
}
// Find the root requests (those without initiators or with external initiators)
const rootRequests = requests.filter(r => {
if (!r.initiator) return true
if (r.initiator.type === 'parser' && r.initiator.url) {
// Check if the initiator URL is in our request list
return !requests.some(req => req.url === r.initiator!.url)
}
return r.initiator.type === 'other' || r.initiator.type === 'preload'
})
// Build chains for each root
const chains = rootRequests.map(root => buildChain(root))
// If the selected request is not in any chain, add it as a standalone chain
const selectedInChains = chains.some(chain =>
findNodeInChain(chain, selectedRequest.requestId)
)
if (!selectedInChains) {
chains.push(buildChain(selectedRequest))
}
return chains
}, [requests, selectedRequest])
const findNodeInChain = (node: ChainNode, requestId: string): boolean => {
if (node.request.requestId === requestId) return true
return node.children.some(child => findNodeInChain(child, requestId))
}
const getResourceTypeIcon = (resourceType: string) => {
switch (resourceType.toLowerCase()) {
case 'document': return '📄'
case 'script': return '📜'
case 'stylesheet': return '🎨'
case 'image': return '🖼️'
case 'font': return '🔤'
case 'xhr': return '📡'
case 'fetch': return '📤'
default: return '📋'
}
}
const truncateUrl = (url: string, maxLength: number = 50) => {
if (url.length <= maxLength) return url
const start = url.substring(0, 20)
const end = url.substring(url.length - 25)
return `${start}...${end}`
}
const renderChainNode = (node: ChainNode) => {
const { request, level, children, isSelected } = node
const indentation = level * 20
return (
<div key={request.requestId} className={styles.chainNodeContainer}>
<div
className={`${styles.chainNode} ${isSelected ? styles.selected : ''}`}
style={{ marginLeft: `${indentation}px` }}
>
<div className={styles.nodeConnector}>
{level > 0 && (
<>
<div className={styles.verticalLine} />
<div className={styles.horizontalLine} />
</>
)}
</div>
<div className={styles.nodeContent}>
<div className={styles.nodeIcon}>
{getResourceTypeIcon(request.resourceType)}
</div>
<div className={styles.nodeDetails}>
<div className={styles.nodeUrl} title={request.url}>
{truncateUrl(request.url)}
</div>
<div className={styles.nodeInfo}>
<span className={styles.resourceType}>{request.resourceType}</span>
{request.initiator && (
<span className={styles.initiatorType}>
via {request.initiator.type}
{request.initiator.fetchType && ` (${request.initiator.fetchType})`}
</span>
)}
</div>
</div>
{isSelected && (
<div className={styles.selectedIndicator}>
📍 Selected
</div>
)}
</div>
</div>
{children.map(child => renderChainNode(child))}
</div>
)
}
if (chainTree.length === 0) {
return (
<div className={styles.noChain}>
No initiator chain information available
</div>
)
}
return (
<div className={styles.chainContainer}>
<div className={styles.chainHeader}>
<h4 className={styles.chainTitle}>🔗 Request Initiator Chain</h4>
<div className={styles.chainDescription}>
Shows how requests are initiated by other requests or by the browser
</div>
</div>
<div className={styles.chainTree}>
{chainTree.map(rootNode => renderChainNode(rootNode))}
</div>
</div>
)
}
export default InitiatorChain

View File

@ -1,256 +0,0 @@
/* Initiator View styles using CSS variables from App.module.css */
.initiatorContainer {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-top: var(--spacing-md);
}
.initiatorHeader {
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--color-primary);
}
.title {
color: var(--color-text-highlight);
font-size: var(--font-size-lg);
font-weight: bold;
margin: 0;
}
/* No initiator state */
.noInitiator {
color: var(--color-text-muted);
font-style: italic;
text-align: center;
padding: var(--spacing-lg);
background: var(--color-bg-light);
border-radius: var(--radius-md);
}
/* Initiator basic information */
.initiatorInfo {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.infoRow {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.infoLabel {
font-weight: 500;
color: var(--color-text);
min-width: 80px;
font-size: var(--font-size-sm);
}
.infoValue {
color: var(--color-text-highlight);
font-size: var(--font-size-base);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
/* Section titles */
.sectionTitle {
color: var(--color-text-highlight);
font-size: var(--font-size-md);
font-weight: bold;
margin: 0 0 var(--spacing-sm) 0;
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
/* Parser information */
.parserInfo {
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.parserDetails {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.parserUrl {
font-size: var(--font-size-base);
}
.parserLocation {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-family: var(--font-family-mono);
}
/* Call stack information */
.stackInfo {
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
}
.callStack {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
max-height: 300px;
overflow-y: auto;
}
.callFrame {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--color-bg-primary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
transition: all 0.2s ease;
}
.callFrame:hover {
background: var(--color-bg-hover);
border-color: var(--color-primary);
}
.frameIndex {
background: var(--color-primary);
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-weight: bold;
flex-shrink: 0;
margin-top: 2px;
}
.frameDetails {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.frameFunction {
font-weight: 500;
color: var(--color-text-highlight);
font-size: var(--font-size-base);
font-family: var(--font-family-mono);
word-break: break-word;
}
.frameLocation {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.frameUrl {
color: var(--color-primary);
text-decoration: none;
font-size: var(--font-size-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
.frameUrl:hover {
color: var(--color-text-highlight);
text-decoration: underline;
}
.framePosition {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 2px 6px;
border-radius: var(--radius-xs);
white-space: nowrap;
}
/* Basic info for other types */
.basicInfo {
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
text-align: center;
}
.basicInfoText {
color: var(--color-text-muted);
font-size: var(--font-size-base);
}
/* URL links */
.urlLink {
color: var(--color-primary);
text-decoration: none;
word-break: break-word;
}
.urlLink:hover {
color: var(--color-text-highlight);
text-decoration: underline;
}
/* Responsive design */
@media (max-width: 768px) {
.initiatorContainer {
padding: var(--spacing-md);
}
.infoRow {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.infoLabel {
min-width: auto;
}
.frameLocation {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.frameUrl {
max-width: 100%;
}
}
@media (max-width: 480px) {
.callFrame {
flex-direction: column;
gap: var(--spacing-xs);
}
.frameIndex {
align-self: flex-start;
}
}

View File

@ -1,158 +0,0 @@
import React from 'react'
import type { HTTPRequest, CallFrame } from './types/httpRequest'
import styles from './InitiatorView.module.css'
interface InitiatorViewProps {
request: HTTPRequest
}
const InitiatorView: React.FC<InitiatorViewProps> = ({ request }) => {
if (!request.initiator) {
return (
<div className={styles.noInitiator}>
No initiator information available
</div>
)
}
const { initiator } = request
const hasStack = initiator.stack && initiator.stack.callFrames.length > 0
const getInitiatorTypeIcon = (type: string) => {
switch (type) {
case 'parser': return '📄'
case 'script': return '📜'
case 'preload': return '⚡'
default: return '❓'
}
}
const getFetchTypeIcon = (fetchType?: string) => {
switch (fetchType) {
case 'script': return '🔧'
case 'link': return '🔗'
case 'fetch': return '📡'
case 'xhr': return '📤'
case 'img': return '🖼️'
default: return '📋'
}
}
const truncateUrl = (url: string, maxLength: number = 60) => {
if (url.length <= maxLength) return url
const start = url.substring(0, 20)
const end = url.substring(url.length - 35)
return `${start}...${end}`
}
const formatLocation = (lineNumber?: number, columnNumber?: number) => {
if (lineNumber !== undefined && columnNumber !== undefined) {
return `${lineNumber}:${columnNumber}`
}
if (lineNumber !== undefined) {
return `${lineNumber}`
}
return '-'
}
const renderCallFrame = (frame: CallFrame, index: number) => (
<div key={index} className={styles.callFrame}>
<div className={styles.frameIndex}>{index + 1}</div>
<div className={styles.frameDetails}>
<div className={styles.frameFunction}>
{frame.functionName || '(anonymous)'}
</div>
<div className={styles.frameLocation}>
<a
href={frame.url}
target="_blank"
rel="noopener noreferrer"
className={styles.frameUrl}
title={frame.url}
>
{truncateUrl(frame.url)}
</a>
<span className={styles.framePosition}>
@ {formatLocation(frame.lineNumber, frame.columnNumber)}
</span>
</div>
</div>
</div>
)
return (
<div className={styles.initiatorContainer}>
<div className={styles.initiatorHeader}>
<h4 className={styles.title}>Request Initiator</h4>
</div>
{/* Initiator Type Information */}
<div className={styles.initiatorInfo}>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Type:</span>
<span className={styles.infoValue}>
{getInitiatorTypeIcon(initiator.type)} {initiator.type}
</span>
</div>
{initiator.fetchType && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Fetch Type:</span>
<span className={styles.infoValue}>
{getFetchTypeIcon(initiator.fetchType)} {initiator.fetchType}
</span>
</div>
)}
</div>
{/* Parser-initiated requests */}
{initiator.type === 'parser' && initiator.url && (
<div className={styles.parserInfo}>
<h5 className={styles.sectionTitle}>📄 Document Parser</h5>
<div className={styles.parserDetails}>
<div className={styles.parserUrl}>
<a
href={initiator.url}
target="_blank"
rel="noopener noreferrer"
className={styles.urlLink}
title={initiator.url}
>
{truncateUrl(initiator.url)}
</a>
</div>
{(initiator.lineNumber || initiator.columnNumber) && (
<div className={styles.parserLocation}>
Line {formatLocation(initiator.lineNumber, initiator.columnNumber)}
</div>
)}
</div>
</div>
)}
{/* Script-initiated requests with call stack */}
{initiator.type === 'script' && hasStack && (
<div className={styles.stackInfo}>
<h5 className={styles.sectionTitle}>📜 Call Stack</h5>
<div className={styles.callStack}>
{initiator.stack!.callFrames.map((frame, index) =>
renderCallFrame(frame, index)
)}
</div>
</div>
)}
{/* Other types without detailed information */}
{!hasStack && initiator.type !== 'parser' && (
<div className={styles.basicInfo}>
<div className={styles.basicInfoText}>
This request was initiated by {initiator.type}
{initiator.fetchType && ` using ${initiator.fetchType}`}
</div>
</div>
)}
</div>
)
}
export default InitiatorView

View File

@ -8,18 +8,6 @@ tr {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
/* Ensure expanded row doesn't inherit hover effects from tbody tr:hover */
.expandedRow:hover {
filter: none !important;
background-color: var(--color-bg-secondary) !important;
border: none !important;
}
.expandedRow:hover td {
filter: none !important;
border: none !important;
}
div.expandedContent { div.expandedContent {
margin: 0 32px; margin: 0 32px;
border: 1px solid #6c757d; border: 1px solid #6c757d;
@ -111,29 +99,3 @@ div.fullWidth {
cursor: help; cursor: help;
word-break: break-all; word-break: break-all;
} }
/* Override status classes to only use foreground colors, not background colors */
.detailListItem.success,
.detailListItem.warning,
.detailListItem.danger,
.timingHighlighted.success,
.timingHighlighted.warning,
.timingHighlighted.danger {
background-color: transparent !important;
}
/* Ensure status colors are applied only to text */
.detailListItem.success,
.timingHighlighted.success {
color: var(--color-success) !important;
}
.detailListItem.warning,
.timingHighlighted.warning {
color: var(--color-warning) !important;
}
.detailListItem.danger,
.timingHighlighted.danger {
color: var(--color-danger) !important;
}

View File

@ -1,29 +1,24 @@
import React from 'react' import React from 'react'
import type { HTTPRequest } from './types/httpRequest' import type { HTTPRequest } from './types/httpRequest'
import InitiatorView from './InitiatorView'
import styles from './RequestRowDetails.module.css' import styles from './RequestRowDetails.module.css'
// Import utility functions // Import utility functions
import { formatDuration, formatSize } from './lib/formatUtils' import { formatDuration, formatSize } from './lib/formatUtils'
import { import {
getTotalResponseTimeClass, getTotalResponseTimeColor,
getQueueAnalysisIcon, getQueueAnalysisIcon,
getCDNIcon, getCDNIcon,
getCDNDisplayName, getCDNDisplayName
getPriorityIcon
} from './lib/colorUtils' } from './lib/colorUtils'
interface RequestRowDetailsProps { interface RequestRowDetailsProps {
request: HTTPRequest request: HTTPRequest
visibleColumns: Record<string, boolean>
} }
const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request, visibleColumns }) => { const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request }) => {
// Calculate the number of visible columns for colSpan
const visibleColumnCount = Object.values(visibleColumns).filter(Boolean).length
return ( return (
<tr key={`${request.requestId}-expanded`} className={styles.expandedRow}> <tr key={`${request.requestId}-expanded`} className={styles.expandedRow}>
<td colSpan={visibleColumnCount}> <td colSpan={18}>
<div className={styles.expandedContent}> <div className={styles.expandedContent}>
{/* Request Details */} {/* Request Details */}
@ -32,12 +27,7 @@ const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request, visibleC
<div className={styles.detailList}> <div className={styles.detailList}>
<div className={styles.detailListItem}><strong>Request ID:</strong> {request.requestId}</div> <div className={styles.detailListItem}><strong>Request ID:</strong> {request.requestId}</div>
<div className={styles.detailListItem}><strong>Method:</strong> {request.method}</div> <div className={styles.detailListItem}><strong>Method:</strong> {request.method}</div>
<div className={styles.detailListItem}> <div className={styles.detailListItem}><strong>Priority:</strong> {request.priority}</div>
<strong>Priority:</strong>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', marginLeft: '4px' }}>
{getPriorityIcon(request.priority)} {request.priority || '-'}
</span>
</div>
<div className={styles.detailListItem}><strong>MIME Type:</strong> {request.mimeType || '-'}</div> <div className={styles.detailListItem}><strong>MIME Type:</strong> {request.mimeType || '-'}</div>
<div className={styles.detailListItem}><strong>Content-Length:</strong> {request.contentLength ? formatSize(request.contentLength) : '-'}</div> <div className={styles.detailListItem}><strong>Content-Length:</strong> {request.contentLength ? formatSize(request.contentLength) : '-'}</div>
<div className={styles.detailListItem}><strong>From Cache:</strong> {request.fromCache ? 'Yes' : 'No'}</div> <div className={styles.detailListItem}><strong>From Cache:</strong> {request.fromCache ? 'Yes' : 'No'}</div>
@ -45,9 +35,6 @@ const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request, visibleC
</div> </div>
</div> </div>
{/* Initiator Information */}
<InitiatorView request={request} />
{/* Network Timing */} {/* Network Timing */}
<div className={styles.detailCard}> <div className={styles.detailCard}>
<h4 className={styles.detailCardTitle}>Network Timing</h4> <h4 className={styles.detailCardTitle}>Network Timing</h4>
@ -63,7 +50,7 @@ const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request, visibleC
</div> </div>
<div className={styles.detailListItem}><strong>Server Latency:</strong> {formatDuration(request.timing.serverLatency)}</div> <div className={styles.detailListItem}><strong>Server Latency:</strong> {formatDuration(request.timing.serverLatency)}</div>
<div className={styles.detailListItem}><strong>Network Duration:</strong> {formatDuration(request.timing.duration)}</div> <div className={styles.detailListItem}><strong>Network Duration:</strong> {formatDuration(request.timing.duration)}</div>
<div className={`${styles.detailListItem} ${styles.timingHighlighted} ${getTotalResponseTimeClass(request.timing.totalResponseTime)}`}> <div className={`${styles.detailListItem} ${styles.timingHighlighted}`} style={{ ...getTotalResponseTimeColor(request.timing.totalResponseTime) }}>
<strong>Total Response Time:</strong> {formatDuration(request.timing.totalResponseTime)} <strong>Total Response Time:</strong> {formatDuration(request.timing.totalResponseTime)}
</div> </div>
<div className={styles.detailListItem}><strong>Network Duration Only:</strong> {formatDuration(request.timing.networkDuration)}</div> <div className={styles.detailListItem}><strong>Network Duration Only:</strong> {formatDuration(request.timing.networkDuration)}</div>

View File

@ -19,35 +19,11 @@
td { td {
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--color-bg);
} }
tr { tr {
border: 1px solid #ffffff; border: 1px solid #ffffff;
} }
/* Only apply hover effects to data rows, not header rows */
tbody tr:hover, tbody tr:hover td {
border: 1px dashed var(--color-bg-hover);
}
tbody tr:hover {
filter: brightness(290%);
}
a { a {
color: var(--color-text); color: var(--color-text);
text-decoration: none; text-decoration: none;
} }
/* Priority cell styling to prevent wrapping */
.priorityCell {
white-space: nowrap;
min-width: 120px;
width: 120px;
text-align: center;
}
.priorityCell span {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
}

View File

@ -13,10 +13,7 @@ import {
getSizeClass, getSizeClass,
getQueueAnalysisIcon, getQueueAnalysisIcon,
getCDNIcon, getCDNIcon,
getCDNDisplayName, getCDNDisplayName, getProtocolClass, getConnectionClass
getPriorityIcon,
getProtocolClass,
getConnectionClass
} from './lib/colorUtils' } from './lib/colorUtils'
import { truncateUrl } from './lib/urlUtils' import { truncateUrl } from './lib/urlUtils'
@ -25,72 +22,42 @@ interface RequestRowSummaryProps {
showQueueAnalysis: boolean showQueueAnalysis: boolean
isExpanded: boolean isExpanded: boolean
onToggleRowExpansion: (requestId: string) => void onToggleRowExpansion: (requestId: string) => void
visibleColumns: Record<string, boolean>
} }
const RequestRowSummary: React.FC<RequestRowSummaryProps> = ({ const RequestRowSummary: React.FC<RequestRowSummaryProps> = ({
request, request,
showQueueAnalysis, showQueueAnalysis,
isExpanded, isExpanded,
onToggleRowExpansion, onToggleRowExpansion
visibleColumns
}) => { }) => {
return ( return (
<> <>
<tr className={styles.default} key={request.requestId} <tr className={styles.default} key={request.requestId}
onClick={() => onToggleRowExpansion(request.requestId)} onClick={() => onToggleRowExpansion(request.requestId)}
> >
{visibleColumns.expand && (
<td> <td>
{isExpanded ? '' : '+'} {isExpanded ? '' : '+'}
</td> </td>
)}
{visibleColumns.method && (
<td> <td>
{request.method} {request.method}
</td> </td>
)}
{visibleColumns.status && (
<td> <td>
{request.statusCode || '-'} {request.statusCode || '-'}
</td> </td>
)}
{visibleColumns.type && (
<td> <td>
{request.resourceType} {request.resourceType}
</td> </td>
)} <td>
{visibleColumns.priority && (
<td className={styles.priorityCell}>
<span>
{getPriorityIcon(request.priority)}
{request.priority || '-'} {request.priority || '-'}
</span>
</td> </td>
)}
{visibleColumns.url && (
<td> <td>
<a href={request.url} target="_blank" rel="noopener noreferrer"> <a href={request.url} target="_blank" rel="noopener noreferrer">
{truncateUrl(request.url)} {truncateUrl(request.url)}
</a> </a>
</td> </td>
)}
{visibleColumns.connectionNumber && (
<td>
{request.connectionNumber || '-'}
</td>
)}
{visibleColumns.requestNumber && (
<td>
{request.requestNumberOnConnection || '-'}
</td>
)}
{visibleColumns.startTime && (
<td> <td>
{formatDuration(request.timing.startOffset)} {formatDuration(request.timing.startOffset)}
</td> </td>
)}
{visibleColumns.queueTime && (
<td className={getConnectionClass(request?.timing?.queueTime ||0)}> <td className={getConnectionClass(request?.timing?.queueTime ||0)}>
<div> <div>
{formatDuration(request.timing.queueTime)} {formatDuration(request.timing.queueTime)}
@ -103,20 +70,16 @@ const RequestRowSummary: React.FC<RequestRowSummaryProps> = ({
)} )}
</div> </div>
</td> </td>
)}
{/* DNS Time */} {/* DNS Time */}
{visibleColumns.dns && (
<td > <td >
{request.timing.dnsStart !== undefined && request.timing.dnsEnd !== undefined && request.timing.dnsStart >= 0 && request.timing.dnsEnd >= 0 {request.timing.dnsStart !== undefined && request.timing.dnsEnd !== undefined && request.timing.dnsStart >= 0 && request.timing.dnsEnd >= 0
? formatDuration((request.timing.dnsEnd || 0) - (request.timing.dnsStart || 0)) ? formatDuration((request.timing.dnsEnd || 0) - (request.timing.dnsStart || 0))
: <span>cached</span> : <span>cached</span>
} }
</td> </td>
)}
{/* Connection Time */} {/* Connection Time */}
{visibleColumns.connection && (
<td className={getConnectionClass((request.timing.connectEnd || 0) - (request.timing.connectStart || 0))}> <td className={getConnectionClass((request.timing.connectEnd || 0) - (request.timing.connectStart || 0))}>
{request.timing.connectStart !== undefined && request.timing.connectEnd !== undefined {request.timing.connectStart !== undefined && request.timing.connectEnd !== undefined
&& request.timing.connectStart >= 0 && request.timing.connectEnd >= 0 && request.timing.connectStart >= 0 && request.timing.connectEnd >= 0
@ -128,53 +91,33 @@ const RequestRowSummary: React.FC<RequestRowSummaryProps> = ({
</span> </span>
} }
</td> </td>
)}
{visibleColumns.serverLatency && (
<td className={`${getServerLatencyClass(request.timing.serverLatency)}`}> <td className={`${getServerLatencyClass(request.timing.serverLatency)}`}>
{formatDuration(request.timing.serverLatency)} {formatDuration(request.timing.serverLatency)}
</td> </td>
)}
{visibleColumns.duration && (
<td className={getDurationClass(request.timing.duration)}> <td className={getDurationClass(request.timing.duration)}>
{formatDuration(request.timing.duration)} {formatDuration(request.timing.duration)}
</td> </td>
)}
{/* Total Response Time */} {/* Total Response Time */}
{visibleColumns.totalResponseTime && (
<td className={`${getTotalResponseTimeClass(request.timing.totalResponseTime)}`}> <td className={`${getTotalResponseTimeClass(request.timing.totalResponseTime)}`}>
{formatDuration(request.timing.totalResponseTime)} {formatDuration(request.timing.totalResponseTime)}
</td> </td>
)}
{/* Data Rate */} {/* Data Rate */}
{visibleColumns.dataRate && (
<td className={`${getDataRateClass(request.encodedDataLength, request.contentLength, request.timing.duration)}`}> <td className={`${getDataRateClass(request.encodedDataLength, request.contentLength, request.timing.duration)}`}>
{formatDataRate(request.encodedDataLength, request.contentLength, request.timing.duration)} {formatDataRate(request.encodedDataLength, request.contentLength, request.timing.duration)}
</td> </td>
)}
{visibleColumns.size && (
<td className={`${getSizeClass(request.encodedDataLength)}`}> <td className={`${getSizeClass(request.encodedDataLength)}`}>
{formatSize(request.encodedDataLength)} {formatSize(request.encodedDataLength)}
</td> </td>
)}
{visibleColumns.contentLength && (
<td className={`${getSizeClass(request.contentLength)}`}> <td className={`${getSizeClass(request.contentLength)}`}>
{request.contentLength ? formatSize(request.contentLength) : '-'} {request.contentLength ? formatSize(request.contentLength) : '-'}
</td> </td>
)}
{visibleColumns.protocol && (
<td className={getProtocolClass(request.protocol)}> <td className={getProtocolClass(request.protocol)}>
{request.protocol || '-'} {request.protocol || '-'}
</td> </td>
)}
{visibleColumns.cdn && (
<td <td
title={request.cdnAnalysis ? title={request.cdnAnalysis ?
`${getCDNDisplayName(request.cdnAnalysis.provider)} ${request.cdnAnalysis.isEdge ? '(Edge)' : '(Origin)'} - ${request.cdnAnalysis.detectionMethod}` : `${getCDNDisplayName(request.cdnAnalysis.provider)} ${request.cdnAnalysis.isEdge ? '(Edge)' : '(Origin)'} - ${request.cdnAnalysis.detectionMethod}` :
@ -182,17 +125,13 @@ const RequestRowSummary: React.FC<RequestRowSummaryProps> = ({
> >
{request.cdnAnalysis ? getCDNIcon(request.cdnAnalysis) : '-'} {request.cdnAnalysis ? getCDNIcon(request.cdnAnalysis) : '-'}
</td> </td>
)}
{visibleColumns.cache && (
<td> <td>
{request.fromCache ? '💾' : request.connectionReused ? '🔄' : '🌐'} {request.fromCache ? '💾' : request.connectionReused ? '🔄' : '🌐'}
</td> </td>
)}
</tr> </tr>
{/* Expanded Row Details */} {/* Expanded Row Details */}
{isExpanded && <RequestRowDetails request={request} visibleColumns={visibleColumns} />} {isExpanded && <RequestRowDetails request={request} />}
</> </>
) )
} }

View File

@ -1,73 +0,0 @@
/* Table styles using CSS variables from App.module.css */
.tableContainer {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
/* Allow tooltips to show above table */
position: relative;
}
.table {
width: 100%;
border-collapse: collapse;
}
.tableHeader {
background: var(--color-bg-light);
}
/* Ensure header row doesn't inherit hover effects */
.tableHeader tr:hover {
filter: none !important;
background: var(--color-bg-light) !important;
border: none !important;
}
.tableHeader tr:hover td,
.tableHeader tr:hover th {
border: 1px solid var(--color-border) !important;
filter: none !important;
}
/* Ensure tooltips in header work properly */
.tableHeader .tooltipContainer {
position: relative;
z-index: 10;
}
.tableHeaderCell {
padding: var(--spacing-sm);
font-size: var(--font-size-md);
font-weight: bold;
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
/* Ensure tooltip positioning context */
position: relative;
overflow: visible;
}
.tableHeaderCell.center {
text-align: center;
}
.tableHeaderCell.left {
text-align: left;
}
.tableHeaderCell.right {
text-align: right;
}
.tableHeaderCell.expandColumn {
width: 30px;
}
/* No results message */
.noResults {
text-align: center;
color: var(--color-text-muted);
padding: 40px;
font-size: var(--font-size-xl);
}

View File

@ -3,7 +3,7 @@ import RequestRowSummary from './RequestRowSummary'
import ScreenshotRow from './ScreenshotRow' import ScreenshotRow from './ScreenshotRow'
import { Tooltip } from '../shared/Tooltip' import { Tooltip } from '../shared/Tooltip'
import { TooltipType } from '../shared/tooltipDefinitions' import { TooltipType } from '../shared/tooltipDefinitions'
import styles from './RequestsTable.module.css' import styles from './HTTPRequestViewer.module.css'
import type { HTTPRequest, ScreenshotEvent } from './types/httpRequest' import type { HTTPRequest, ScreenshotEvent } from './types/httpRequest'
interface RequestsTableProps { interface RequestsTableProps {
@ -19,7 +19,6 @@ interface RequestsTableProps {
// Display options // Display options
showQueueAnalysis: boolean showQueueAnalysis: boolean
visibleColumns: Record<string, boolean>
// Row expansion state // Row expansion state
expandedRows: Set<string> expandedRows: Set<string>
@ -32,7 +31,6 @@ const RequestsTable: React.FC<RequestsTableProps> = ({
paginatedTimelineEntries, paginatedTimelineEntries,
paginatedRequests, paginatedRequests,
showQueueAnalysis, showQueueAnalysis,
visibleColumns,
expandedRows, expandedRows,
onToggleRowExpansion onToggleRowExpansion
}) => { }) => {
@ -43,153 +41,101 @@ const RequestsTable: React.FC<RequestsTableProps> = ({
<table className={styles.table}> <table className={styles.table}>
<thead className={styles.tableHeader}> <thead className={styles.tableHeader}>
<tr> <tr>
{visibleColumns.expand && (
<th className={`${styles.tableHeaderCell} ${styles.center} ${styles.expandColumn}`}> <th className={`${styles.tableHeaderCell} ${styles.center} ${styles.expandColumn}`}>
<Tooltip type={TooltipType.EXPAND_ROW}> <Tooltip type={TooltipType.EXPAND_ROW}>
Expand Expand
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.method && (
<th className={`${styles.tableHeaderCell} ${styles.left}`}> <th className={`${styles.tableHeaderCell} ${styles.left}`}>
<Tooltip type={TooltipType.HTTP_METHOD}> <Tooltip type={TooltipType.HTTP_METHOD}>
Method Method
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.status && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}> <th className={`${styles.tableHeaderCell} ${styles.center}`}>
<Tooltip type={TooltipType.HTTP_STATUS}> <Tooltip type={TooltipType.HTTP_STATUS}>
Status Status
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.type && (
<th className={`${styles.tableHeaderCell} ${styles.left}`}> <th className={`${styles.tableHeaderCell} ${styles.left}`}>
<Tooltip type={TooltipType.RESOURCE_TYPE}> <Tooltip type={TooltipType.RESOURCE_TYPE}>
Type Type
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.priority && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}> <th className={`${styles.tableHeaderCell} ${styles.center}`}>
<Tooltip type={TooltipType.REQUEST_PRIORITY}> <Tooltip type={TooltipType.REQUEST_PRIORITY}>
Priority Priority
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.url && (
<th className={`${styles.tableHeaderCell} ${styles.left}`}> <th className={`${styles.tableHeaderCell} ${styles.left}`}>
<Tooltip type={TooltipType.REQUEST_URL}> <Tooltip type={TooltipType.REQUEST_URL}>
URL URL
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.connectionNumber && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}>
<Tooltip type={TooltipType.CONNECTION_NUMBER}>
Connection #
</Tooltip>
</th>
)}
{visibleColumns.requestNumber && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}>
<Tooltip type={TooltipType.REQUEST_NUMBER}>
Request #
</Tooltip>
</th>
)}
{visibleColumns.startTime && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}> <th className={`${styles.tableHeaderCell} ${styles.right}`}>
<Tooltip type={TooltipType.START_TIME}> <Tooltip type={TooltipType.START_TIME}>
Start Time Start Time
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.queueTime && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}> <th className={`${styles.tableHeaderCell} ${styles.right}`}>
<Tooltip type={TooltipType.QUEUE_TIME}> <Tooltip type={TooltipType.QUEUE_TIME}>
Queue Time Queue Time
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.dns && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}> <th className={`${styles.tableHeaderCell} ${styles.right}`}>
<Tooltip type={TooltipType.DNS_TIME}> <Tooltip type={TooltipType.DNS_TIME}>
DNS DNS
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.connection && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}> <th className={`${styles.tableHeaderCell} ${styles.right}`}>
<Tooltip type={TooltipType.CONNECTION_TIME}> <Tooltip type={TooltipType.CONNECTION_TIME}>
Connection Connection
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.serverLatency && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}> <th className={`${styles.tableHeaderCell} ${styles.right}`}>
<Tooltip type={TooltipType.SERVER_LATENCY}> <Tooltip type={TooltipType.SERVER_LATENCY}>
Server Latency Server Latency
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.duration && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}> <th className={`${styles.tableHeaderCell} ${styles.right}`}>
<Tooltip type={TooltipType.REQUEST_DURATION}> <Tooltip type={TooltipType.REQUEST_DURATION}>
Duration Duration
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.totalResponseTime && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}> <th className={`${styles.tableHeaderCell} ${styles.right}`}>
<Tooltip type={TooltipType.TOTAL_RESPONSE_TIME}> <Tooltip type={TooltipType.TOTAL_RESPONSE_TIME}>
Total Response Time Total Response Time
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.dataRate && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}> <th className={`${styles.tableHeaderCell} ${styles.right}`}>
<Tooltip type={TooltipType.DATA_RATE}> <Tooltip type={TooltipType.DATA_RATE}>
Data Rate Data Rate
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.size && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}> <th className={`${styles.tableHeaderCell} ${styles.right}`}>
<Tooltip type={TooltipType.TRANSFER_SIZE}> <Tooltip type={TooltipType.TRANSFER_SIZE}>
Size Size
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.contentLength && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}> <th className={`${styles.tableHeaderCell} ${styles.right}`}>
<Tooltip type={TooltipType.CONTENT_LENGTH}> <Tooltip type={TooltipType.CONTENT_LENGTH}>
Content-Length Content-Length
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.protocol && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}> <th className={`${styles.tableHeaderCell} ${styles.center}`}>
<Tooltip type={TooltipType.HTTP_PROTOCOL}> <Tooltip type={TooltipType.HTTP_PROTOCOL}>
Protocol Protocol
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.cdn && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}> <th className={`${styles.tableHeaderCell} ${styles.center}`}>
<Tooltip type={TooltipType.CDN_DETECTION}> <Tooltip type={TooltipType.CDN_DETECTION}>
CDN CDN
</Tooltip> </Tooltip>
</th> </th>
)}
{visibleColumns.cache && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}> <th className={`${styles.tableHeaderCell} ${styles.center}`}>
<Tooltip type={TooltipType.CACHE_STATUS}> <Tooltip type={TooltipType.CACHE_STATUS}>
Cache Cache
</Tooltip> </Tooltip>
</th> </th>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -217,7 +163,6 @@ const RequestsTable: React.FC<RequestsTableProps> = ({
showQueueAnalysis={showQueueAnalysis} showQueueAnalysis={showQueueAnalysis}
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleRowExpansion={onToggleRowExpansion} onToggleRowExpansion={onToggleRowExpansion}
visibleColumns={visibleColumns}
/> />
) )
})} })}

View File

@ -166,17 +166,3 @@ export const getCDNDisplayName = (provider: CDNAnalysis['provider']): string =>
default: return 'Unknown CDN' default: return 'Unknown CDN'
} }
} }
export const getPriorityIcon = (priority?: string): string => {
if (!priority) return ''
const upperPriority = priority.toUpperCase()
switch (upperPriority) {
case 'VERYHIGH': return '🔥' // Very high priority - fire/urgent
case 'HIGH': return '🔺' // High priority - red triangle up
case 'MEDIUM': return '🟡' // Medium priority - yellow circle
case 'LOW': return '🔹' // Low priority - red triangle down
case 'VERYLOW': return '🐢' // Very low priority - small blue diamond
default: return '' // Unknown/empty priority
}
}

View File

@ -1,67 +0,0 @@
import type { HTTPRequest } from '../types/httpRequest'
/**
* Assigns connection numbers and request numbers to HTTP requests based on:
* 1. Actual connectionId from Chrome DevTools trace data
* 2. Sequential numbering (1, 2, 3...) based on chronological first-seen order
* 3. Request ordering within each connection by start time
*/
export const assignConnectionNumbers = (requests: HTTPRequest[]): HTTPRequest[] => {
// Sort all requests by start time to process in chronological order
const chronologicalRequests = [...requests].sort((a, b) => a.timing.start - b.timing.start)
// Track first-seen order of connection IDs
const connectionIdToNumber = new Map<number, number>()
let nextConnectionNumber = 1
// First pass: assign connection numbers based on first-seen order
chronologicalRequests.forEach(request => {
if (request.connectionId !== undefined && !connectionIdToNumber.has(request.connectionId)) {
connectionIdToNumber.set(request.connectionId, nextConnectionNumber++)
}
})
// Group requests by connectionId and sort within each connection by start time
const requestsByConnection = new Map<number, HTTPRequest[]>()
const requestsWithoutConnection: HTTPRequest[] = []
requests.forEach(request => {
if (request.connectionId !== undefined) {
if (!requestsByConnection.has(request.connectionId)) {
requestsByConnection.set(request.connectionId, [])
}
requestsByConnection.get(request.connectionId)!.push(request)
} else {
requestsWithoutConnection.push(request)
}
})
// Sort requests within each connection by start time
requestsByConnection.forEach(connectionRequests => {
connectionRequests.sort((a, b) => a.timing.start - b.timing.start)
})
// Assign connection numbers and request numbers
const processedRequests = requests.map(request => {
if (request.connectionId !== undefined) {
const connectionNumber = connectionIdToNumber.get(request.connectionId) || 0
const connectionRequests = requestsByConnection.get(request.connectionId) || []
const requestNumberOnConnection = connectionRequests.findIndex(r => r.requestId === request.requestId) + 1
return {
...request,
connectionNumber,
requestNumberOnConnection
}
} else {
// Handle requests without connection ID (show as unknown)
return {
...request,
connectionNumber: undefined,
requestNumberOnConnection: undefined
}
}
})
return processedRequests
}

View File

@ -1,206 +0,0 @@
import type { HTTPRequest } from '../types/httpRequest'
export interface CSVExportOptions {
includeHeaders?: boolean
selectedColumns?: string[]
}
const DEFAULT_COLUMNS = [
'method',
'url',
'hostname',
'statusCode',
'resourceType',
'priority',
'protocol',
'connectionNumber',
'requestNumberOnConnection',
'timing.start',
'timing.queueTime',
'dnsTime',
'connectionTime',
'timing.serverLatency',
'timing.duration',
'timing.totalResponseTime',
'dataRate',
'size',
'contentLength',
'fromCache',
'connectionReused',
'cdnAnalysis.provider',
'queueAnalysis.reason'
]
// Format a value for CSV output
const formatCSVValue = (value: any): string => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'number') {
return value.toString()
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false'
}
const stringValue = value.toString()
// Escape quotes and wrap in quotes if contains comma, quote, or newline
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
return `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
// Calculate derived values for CSV export
const calculateDerivedValue = (request: HTTPRequest, path: string): any => {
switch (path) {
case 'dnsTime':
if (request.timing.dnsStart !== undefined && request.timing.dnsEnd !== undefined &&
request.timing.dnsStart >= 0 && request.timing.dnsEnd >= 0) {
return request.timing.dnsEnd - request.timing.dnsStart
}
return null
case 'connectionTime':
if (request.timing.connectStart !== undefined && request.timing.connectEnd !== undefined &&
request.timing.connectStart >= 0 && request.timing.connectEnd >= 0) {
return request.timing.connectEnd - request.timing.connectStart
}
return null
case 'dataRate':
if (!request.timing.duration || request.timing.duration <= 0) return null
const bytes = request.contentLength && request.contentLength > 0 ? request.contentLength : request.encodedDataLength
if (!bytes) return null
const durationSeconds = request.timing.duration / 1000000
return bytes / durationSeconds // bytes per second
case 'size':
return request.encodedDataLength || request.contentLength || null
default:
return getNestedValue(request, path)
}
}
// Get nested property value from object
const getNestedValue = (obj: any, path: string): any => {
return path.split('.').reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : null
}, obj)
}
// Convert HTTP requests to CSV format
export const exportRequestsToCSV = (
requests: HTTPRequest[],
options: CSVExportOptions = {}
): string => {
const {
includeHeaders = true,
selectedColumns = DEFAULT_COLUMNS
} = options
if (requests.length === 0) {
return includeHeaders ? selectedColumns.join(',') + '\n' : ''
}
const lines: string[] = []
// Add headers if requested
if (includeHeaders) {
const headers = selectedColumns.map(col => {
// Add units to timing-related headers
switch (col) {
case 'timing.start':
return 'Start Time (μs)'
case 'timing.queueTime':
return 'Queue Time (μs)'
case 'dnsTime':
return 'DNS Time (μs)'
case 'connectionTime':
return 'Connection Time (μs)'
case 'timing.serverLatency':
return 'Server Latency (μs)'
case 'timing.duration':
return 'Duration (μs)'
case 'timing.totalResponseTime':
return 'Total Response Time (μs)'
case 'timing.downloadTime':
return 'Download Time (μs)'
case 'dataRate':
return 'Data Rate (bytes/sec)'
case 'size':
return 'Size (bytes)'
case 'contentLength':
return 'Content Length (bytes)'
case 'encodedDataLength':
return 'Encoded Data Length (bytes)'
default:
// Clean up column names for other headers
return col
.replace(/([A-Z])/g, ' $1') // Add space before capitals
.replace(/^\w/, c => c.toUpperCase()) // Capitalize first letter
.replace(/\./g, ' ') // Replace dots with spaces
.trim()
}
})
lines.push(headers.map(formatCSVValue).join(','))
}
// Add data rows
for (const request of requests) {
const values = selectedColumns.map(column => {
const value = calculateDerivedValue(request, column)
return formatCSVValue(value)
})
lines.push(values.join(','))
}
return lines.join('\n')
}
// Download CSV file
export const downloadCSV = (csvContent: string, filename: string = 'http-requests.csv'): void => {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
if (link.download !== undefined) {
// Use HTML5 download attribute
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', filename)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
}
// Get all available columns from HTTP requests
export const getAvailableColumns = (requests: HTTPRequest[]): string[] => {
if (requests.length === 0) {
return DEFAULT_COLUMNS
}
const columns = new Set<string>()
const extractColumns = (obj: any, prefix = ''): void => {
Object.keys(obj).forEach(key => {
const fullKey = prefix ? `${prefix}.${key}` : key
const value = obj[key]
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
// Recursively extract nested properties
extractColumns(value, fullKey)
} else {
columns.add(fullKey)
}
})
}
// Extract columns from first request
extractColumns(requests[0])
return Array.from(columns).sort()
}

View File

@ -43,24 +43,6 @@ export const processHTTPRequests = (traceEvents: TraceEvent[]): HTTPRequest[] =>
request.resourceType = args.data.resourceType || '' request.resourceType = args.data.resourceType || ''
request.priority = args.data.priority || '' request.priority = args.data.priority || ''
request.timing.start = Math.min(request.timing.start, event.ts) request.timing.start = Math.min(request.timing.start, event.ts)
// Extract connection ID if available
if (args.data.connectionId !== undefined) {
request.connectionId = args.data.connectionId
}
// Extract initiator information
if (args.data.initiator) {
const initiator = args.data.initiator
request.initiator = {
type: initiator.type || 'other',
fetchType: initiator.fetchType,
url: initiator.url,
lineNumber: initiator.lineNumber,
columnNumber: initiator.columnNumber,
stack: initiator.stack
}
}
break break
case 'ResourceReceiveResponse': case 'ResourceReceiveResponse':
@ -70,11 +52,6 @@ export const processHTTPRequests = (traceEvents: TraceEvent[]): HTTPRequest[] =>
request.protocol = args.data.protocol request.protocol = args.data.protocol
request.responseHeaders = args.data.headers request.responseHeaders = args.data.headers
// Extract connection ID if available (fallback if not set in SendRequest)
if (args.data.connectionId !== undefined && !request.connectionId) {
request.connectionId = args.data.connectionId
}
// Extract content-length from response headers // Extract content-length from response headers
if (request.responseHeaders) { if (request.responseHeaders) {
const contentLengthHeader = request.responseHeaders.find( const contentLengthHeader = request.responseHeaders.find(

View File

@ -23,27 +23,6 @@ export interface ScreenshotEvent {
index: number index: number
} }
export interface CallFrame {
scriptId: string
url: string
lineNumber: number
columnNumber: number
functionName: string
}
export interface InitiatorStack {
callFrames: CallFrame[]
}
export interface RequestInitiator {
type: 'parser' | 'script' | 'preload' | 'other'
fetchType?: 'script' | 'link' | 'fetch' | 'xhr' | 'img' | 'other'
url?: string
lineNumber?: number
columnNumber?: number
stack?: InitiatorStack
}
export interface HTTPRequest { export interface HTTPRequest {
requestId: string requestId: string
url: string url: string
@ -51,9 +30,6 @@ export interface HTTPRequest {
method: string method: string
resourceType: string resourceType: string
priority: string priority: string
connectionId?: number
connectionNumber?: number
requestNumberOnConnection?: number
statusCode?: number statusCode?: number
mimeType?: string mimeType?: string
protocol?: string protocol?: string
@ -92,5 +68,4 @@ export interface HTTPRequest {
connectionReused: boolean connectionReused: boolean
queueAnalysis?: QueueAnalysis queueAnalysis?: QueueAnalysis
cdnAnalysis?: CDNAnalysis cdnAnalysis?: CDNAnalysis
initiator?: RequestInitiator
} }

View File

@ -1,184 +0,0 @@
/* JavaScript Events Table styles using CSS variables from App.module.css */
.tableContainer {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
position: relative;
}
.table {
width: 100%;
border-collapse: collapse;
}
.tableHeader {
background: var(--color-bg-light);
}
.tableHeaderCell {
padding: var(--spacing-sm);
font-size: var(--font-size-md);
font-weight: bold;
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
position: relative;
white-space: nowrap;
}
.tableHeaderCell.center {
text-align: center;
}
.tableHeaderCell.left {
text-align: left;
}
.tableHeaderCell.right {
text-align: right;
}
.eventRow {
border-bottom: 1px solid var(--color-border);
transition: background-color 0.2s ease;
}
.eventRow:hover {
background: var(--color-bg-hover);
}
.eventRow td {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-base);
border-right: 1px solid var(--color-border-light);
}
.eventRow td:last-child {
border-right: none;
}
/* Event icon column */
.eventIcon {
text-align: center;
font-size: var(--font-size-lg);
width: 40px;
min-width: 40px;
}
.eventIcon.primary { color: var(--color-primary); }
.eventIcon.secondary { color: var(--color-text-muted); }
.eventIcon.success { color: var(--color-success); }
.eventIcon.warning { color: var(--color-warning); }
.eventIcon.danger { color: var(--color-danger); }
.eventIcon.info { color: var(--color-primary); }
.eventIcon.muted { color: var(--color-text-muted); }
/* Event type column */
.eventType {
font-weight: 500;
color: var(--color-text);
white-space: nowrap;
min-width: 120px;
}
/* Duration column with performance indicators */
.duration {
text-align: right;
font-family: var(--font-family-mono);
font-weight: 500;
min-width: 80px;
white-space: nowrap;
}
.duration.success {
color: var(--color-success);
background-color: transparent;
}
.duration.warning {
color: var(--color-warning);
background-color: transparent;
}
.duration.danger {
color: var(--color-danger);
background-color: transparent;
}
/* URL column */
.url {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.urlLink {
color: var(--color-primary);
text-decoration: none;
}
.urlLink:hover {
color: var(--color-text-highlight);
text-decoration: underline;
}
.inlineScript {
color: var(--color-text-muted);
font-style: italic;
}
/* Location column */
.location {
text-align: center;
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
min-width: 80px;
white-space: nowrap;
}
/* Script type column */
.scriptType {
text-align: center;
font-size: var(--font-size-sm);
min-width: 80px;
white-space: nowrap;
}
.scriptType.primary { color: var(--color-primary); }
.scriptType.secondary { color: var(--color-text-muted); }
.scriptType.success { color: var(--color-success); }
.scriptType.warning { color: var(--color-warning); }
.scriptType.info { color: var(--color-primary); }
.scriptType.muted { color: var(--color-text-muted); }
/* Streaming column */
.streaming {
text-align: center;
font-size: var(--font-size-lg);
width: 50px;
min-width: 50px;
}
/* Start time column */
.startTime {
text-align: right;
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
min-width: 100px;
white-space: nowrap;
}
/* No results message */
.noResults {
text-align: center;
color: var(--color-text-muted);
padding: var(--spacing-lg);
font-size: var(--font-size-xl);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}

View File

@ -1,147 +0,0 @@
import React from 'react'
import type { JavaScriptEvent } from './types/javascriptEvent'
import {
formatDuration,
formatEventType,
getEventIcon,
truncateUrl,
getScriptType,
formatLocation
} from './lib/formatUtils'
import {
getDurationClass,
getEventTypeClass,
getScriptTypeClass,
getStreamingIndicator
} from './lib/colorUtils'
import styles from './JavaScriptEventsTable.module.css'
interface JavaScriptEventsTableProps {
events: JavaScriptEvent[]
visibleColumns: Record<string, boolean>
}
const JavaScriptEventsTable: React.FC<JavaScriptEventsTableProps> = ({
events,
visibleColumns
}) => {
if (events.length === 0) {
return (
<div className={styles.noResults}>
No JavaScript events found in the trace data
</div>
)
}
return (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
{visibleColumns.icon && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}>
Type
</th>
)}
{visibleColumns.eventType && (
<th className={`${styles.tableHeaderCell} ${styles.left}`}>
Event
</th>
)}
{visibleColumns.duration && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}>
Duration
</th>
)}
{visibleColumns.url && (
<th className={`${styles.tableHeaderCell} ${styles.left}`}>
Script / URL
</th>
)}
{visibleColumns.location && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}>
Location
</th>
)}
{visibleColumns.scriptType && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}>
Script Type
</th>
)}
{visibleColumns.streaming && (
<th className={`${styles.tableHeaderCell} ${styles.center}`}>
Streaming
</th>
)}
{visibleColumns.startTime && (
<th className={`${styles.tableHeaderCell} ${styles.right}`}>
Start Time
</th>
)}
</tr>
</thead>
<tbody>
{events.map((event) => (
<tr key={event.eventId} className={styles.eventRow}>
{visibleColumns.icon && (
<td className={`${styles.eventIcon} ${getEventTypeClass(event.name)}`}>
{getEventIcon(event.name)}
</td>
)}
{visibleColumns.eventType && (
<td className={styles.eventType}>
{formatEventType(event.name)}
</td>
)}
{visibleColumns.duration && (
<td className={`${styles.duration} ${getDurationClass(event.timing.durationMs)}`}>
{formatDuration(event.timing.duration)}
</td>
)}
{visibleColumns.url && (
<td className={styles.url} title={event.url}>
{event.isInlineScript ? (
<span className={styles.inlineScript}>
📄 Inline script
</span>
) : (
<a
href={event.url}
target="_blank"
rel="noopener noreferrer"
className={styles.urlLink}
>
{truncateUrl(event.url || '')}
</a>
)}
</td>
)}
{visibleColumns.location && (
<td className={styles.location}>
{formatLocation(event.lineNumber, event.columnNumber)}
</td>
)}
{visibleColumns.scriptType && (
<td className={`${styles.scriptType} ${getScriptTypeClass(getScriptType(event))}`}>
{getScriptType(event)}
</td>
)}
{visibleColumns.streaming && (
<td className={styles.streaming} title={event.v8Data?.notStreamedReason}>
{getStreamingIndicator(event.v8Data?.streamed, event.v8Data?.notStreamedReason)}
</td>
)}
{visibleColumns.startTime && (
<td className={styles.startTime}>
{formatDuration(event.timing.startTime)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)
}
export default JavaScriptEventsTable

View File

@ -1,182 +0,0 @@
/* JavaScript Summary styles using CSS variables from App.module.css */
.summaryContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
/* Overview Stats Grid */
.statsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md);
}
.statCard {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-md);
text-align: center;
transition: all 0.2s ease;
}
.statCard:hover {
background: var(--color-bg-hover);
border-color: var(--color-primary);
}
.statValue {
font-size: var(--font-size-xxl);
font-weight: bold;
color: var(--color-text-highlight);
margin-bottom: var(--spacing-xs);
font-family: var(--font-family-mono);
}
.statLabel {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Breakdown Section */
.breakdownSection {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
}
.sectionTitle {
color: var(--color-text-highlight);
font-size: var(--font-size-xl);
font-weight: bold;
margin: 0 0 var(--spacing-md) 0;
border-bottom: 2px solid var(--color-primary);
padding-bottom: var(--spacing-xs);
}
.eventTypeGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md);
}
.eventTypeCard {
display: flex;
align-items: center;
gap: var(--spacing-md);
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
transition: all 0.2s ease;
}
.eventTypeCard:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.eventTypeIcon {
font-size: var(--font-size-xxl);
line-height: 1;
}
.eventTypeInfo {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.eventTypeCount {
font-size: var(--font-size-lg);
font-weight: bold;
color: var(--color-text-highlight);
font-family: var(--font-family-mono);
}
.eventTypeLabel {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
/* Top Scripts Section */
.topScriptsSection {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
}
.scriptsList {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.scriptCard {
display: flex;
align-items: center;
gap: var(--spacing-md);
background: var(--color-bg-light);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--spacing-md);
transition: all 0.2s ease;
}
.scriptCard:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.scriptRank {
background: var(--color-primary);
color: white;
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
font-weight: bold;
flex-shrink: 0;
}
.scriptInfo {
flex: 1;
min-width: 0;
}
.scriptUrl {
font-size: var(--font-size-base);
color: var(--color-text);
margin-bottom: var(--spacing-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scriptStats {
display: flex;
gap: var(--spacing-md);
align-items: center;
}
.scriptTime {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-primary);
font-weight: 500;
}
.scriptEvents {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}

View File

@ -1,111 +0,0 @@
import React from 'react'
import type { JavaScriptSummary as JSummary } from './types/javascriptEvent'
import { formatDuration } from './lib/formatUtils'
import styles from './JavaScriptSummary.module.css'
interface JavaScriptSummaryProps {
summary: JSummary
}
const JavaScriptSummary: React.FC<JavaScriptSummaryProps> = ({ summary }) => {
const {
totalEvents,
totalExecutionTime,
totalCompilationTime,
totalEvaluationTime,
eventsByType,
scriptsByUrl
} = summary
const topScripts = Array.from(scriptsByUrl.values())
.sort((a, b) => b.totalTime - a.totalTime)
.slice(0, 5)
return (
<div className={styles.summaryContainer}>
{/* Overview Stats */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<div className={styles.statValue}>{totalEvents}</div>
<div className={styles.statLabel}>Total Events</div>
</div>
<div className={styles.statCard}>
<div className={styles.statValue}>{formatDuration(totalExecutionTime)}</div>
<div className={styles.statLabel}>Total Time</div>
</div>
<div className={styles.statCard}>
<div className={styles.statValue}>{formatDuration(totalCompilationTime)}</div>
<div className={styles.statLabel}>Compilation</div>
</div>
<div className={styles.statCard}>
<div className={styles.statValue}>{formatDuration(totalEvaluationTime)}</div>
<div className={styles.statLabel}>Evaluation</div>
</div>
</div>
{/* Event Type Breakdown */}
<div className={styles.breakdownSection}>
<h3 className={styles.sectionTitle}>Event Type Breakdown</h3>
<div className={styles.eventTypeGrid}>
<div className={styles.eventTypeCard}>
<div className={styles.eventTypeIcon}></div>
<div className={styles.eventTypeInfo}>
<div className={styles.eventTypeCount}>{eventsByType.compilation}</div>
<div className={styles.eventTypeLabel}>Compilation</div>
</div>
</div>
<div className={styles.eventTypeCard}>
<div className={styles.eventTypeIcon}></div>
<div className={styles.eventTypeInfo}>
<div className={styles.eventTypeCount}>{eventsByType.evaluation}</div>
<div className={styles.eventTypeLabel}>Evaluation</div>
</div>
</div>
<div className={styles.eventTypeCard}>
<div className={styles.eventTypeIcon}>📞</div>
<div className={styles.eventTypeInfo}>
<div className={styles.eventTypeCount}>{eventsByType.execution}</div>
<div className={styles.eventTypeLabel}>Execution</div>
</div>
</div>
<div className={styles.eventTypeCard}>
<div className={styles.eventTypeIcon}>🔧</div>
<div className={styles.eventTypeInfo}>
<div className={styles.eventTypeCount}>{eventsByType.other}</div>
<div className={styles.eventTypeLabel}>Other</div>
</div>
</div>
</div>
</div>
{/* Top Scripts by Time */}
<div className={styles.topScriptsSection}>
<h3 className={styles.sectionTitle}>Top Scripts by Execution Time</h3>
<div className={styles.scriptsList}>
{topScripts.map((script, index) => (
<div key={script.url} className={styles.scriptCard}>
<div className={styles.scriptRank}>#{index + 1}</div>
<div className={styles.scriptInfo}>
<div className={styles.scriptUrl} title={script.url}>
{script.isThirdParty ? '🌐' : '🏠'} {script.url}
</div>
<div className={styles.scriptStats}>
<span className={styles.scriptTime}>{formatDuration(script.totalTime)}</span>
<span className={styles.scriptEvents}>{script.events.length} events</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
export default JavaScriptSummary

View File

@ -1,155 +0,0 @@
/* JavaScript Viewer styles using CSS variables from App.module.css */
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
min-height: 100vh;
background: var(--color-bg-primary);
}
.title {
color: var(--color-text-highlight);
font-size: var(--font-size-xxl);
font-weight: bold;
margin: 0;
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--color-primary);
}
/* Loading and error states */
.loading,
.error,
.noData {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
font-size: var(--font-size-lg);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
}
.loading {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--color-primary);
}
.error {
background: var(--color-bg-secondary);
border: 1px solid var(--color-danger);
color: var(--color-danger);
}
.noData {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
/* Filters section */
.filtersContainer {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
}
.filtersTitle {
color: var(--color-text-highlight);
font-size: var(--font-size-xl);
font-weight: bold;
margin: 0 0 var(--spacing-md) 0;
border-bottom: 2px solid var(--color-primary);
padding-bottom: var(--spacing-xs);
}
.filters {
display: flex;
gap: var(--spacing-lg);
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: var(--spacing-md);
}
.filterGroup {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
min-width: 150px;
}
.filterLabel {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
margin-bottom: var(--spacing-xs);
}
.filterSelect,
.filterInput {
padding: var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-primary);
color: var(--color-text);
font-size: var(--font-size-base);
transition: all 0.2s ease;
}
.filterSelect:focus,
.filterInput:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.1);
}
.filterSelect {
cursor: pointer;
}
.filterInput::placeholder {
color: var(--color-text-muted);
}
/* Results info */
.resultsInfo {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: 1px solid var(--color-border-light);
}
/* Responsive design */
@media (max-width: 768px) {
.container {
padding: var(--spacing-md);
gap: var(--spacing-md);
}
.filters {
flex-direction: column;
gap: var(--spacing-md);
}
.filterGroup {
min-width: auto;
}
.title {
font-size: var(--font-size-xl);
}
}
@media (max-width: 480px) {
.container {
padding: var(--spacing-sm);
}
.filtersContainer {
padding: var(--spacing-md);
}
}

View File

@ -1,160 +0,0 @@
import { useState, useMemo } from 'react'
import { useDatabaseTraceData } from '../../hooks/useDatabaseTraceData'
import { processJavaScriptEvents, calculateJavaScriptSummary } from './lib/javascriptProcessor'
import JavaScriptSummary from './JavaScriptSummary'
import JavaScriptEventsTable from './JavaScriptEventsTable'
import styles from './JavaScriptViewer.module.css'
interface JavaScriptViewerProps {
traceId: string | null
}
const JavaScriptViewer: React.FC<JavaScriptViewerProps> = ({ traceId }) => {
const { traceData, loading, error } = useDatabaseTraceData(traceId)
// Column visibility state for the table
const [visibleColumns] = useState(() => ({
icon: true,
eventType: true,
duration: true,
url: true,
location: true,
scriptType: true,
streaming: true,
startTime: false
}))
// Filter states
const [eventTypeFilter, setEventTypeFilter] = useState<string>('all')
const [searchTerm, setSearchTerm] = useState('')
const [minDuration, setMinDuration] = useState<number>(0)
const jsEvents = useMemo(() => {
if (!traceData) return []
return processJavaScriptEvents(traceData.traceEvents)
}, [traceData])
const filteredEvents = useMemo(() => {
let events = jsEvents
// Filter by event type
if (eventTypeFilter !== 'all') {
events = events.filter(event => event.name === eventTypeFilter)
}
// Filter by minimum duration (in milliseconds)
if (minDuration > 0) {
events = events.filter(event => event.timing.durationMs >= minDuration)
}
// Filter by search term
if (searchTerm) {
const term = searchTerm.toLowerCase()
events = events.filter(event =>
event.url?.toLowerCase().includes(term) ||
event.fileName?.toLowerCase().includes(term) ||
event.name.toLowerCase().includes(term)
)
}
return events
}, [jsEvents, eventTypeFilter, minDuration, searchTerm])
const summary = useMemo(() => {
return calculateJavaScriptSummary(jsEvents)
}, [jsEvents])
// Extract unique event types for filter dropdown
const eventTypes = useMemo(() => {
const types = Array.from(new Set(jsEvents.map(event => event.name)))
return types.sort()
}, [jsEvents])
if (loading) {
return (
<div className={styles.loading}>
Loading JavaScript performance data...
</div>
)
}
if (error) {
return (
<div className={styles.error}>
Error loading trace data: {error}
</div>
)
}
if (!traceData) {
return (
<div className={styles.noData}>
No trace data available
</div>
)
}
return (
<div className={styles.container}>
<h2 className={styles.title}>JavaScript Performance Analysis</h2>
{/* Summary Statistics */}
<JavaScriptSummary summary={summary} />
{/* Filters */}
<div className={styles.filtersContainer}>
<h3 className={styles.filtersTitle}>Filters</h3>
<div className={styles.filters}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Event Type:</label>
<select
value={eventTypeFilter}
onChange={(e) => setEventTypeFilter(e.target.value)}
className={styles.filterSelect}
>
<option value="all">All Events</option>
{eventTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Min Duration (ms):</label>
<input
type="number"
min="0"
step="0.1"
value={minDuration}
onChange={(e) => setMinDuration(parseFloat(e.target.value) || 0)}
className={styles.filterInput}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Search:</label>
<input
type="text"
placeholder="Search URLs, files, or event types..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={styles.filterInput}
/>
</div>
</div>
<div className={styles.resultsInfo}>
Showing {filteredEvents.length} of {jsEvents.length} JavaScript events
</div>
</div>
{/* Events Table */}
<JavaScriptEventsTable
events={filteredEvents}
visibleColumns={visibleColumns}
/>
</div>
)
}
export default JavaScriptViewer

View File

@ -1,64 +0,0 @@
// Color utilities for JavaScript performance indicators
export const getDurationClass = (durationMs: number): string => {
if (durationMs > 50) {
return 'danger' // Red for > 50ms
} else if (durationMs >= 10) {
return 'warning' // Orange for 10-50ms
} else {
return 'success' // Green for < 10ms
}
}
export const getEventTypeClass = (eventName: string): string => {
switch (eventName) {
case 'EvaluateScript':
return 'primary'
case 'v8.compile':
return 'secondary'
case 'v8.callFunction':
return 'info'
case 'v8.newInstance':
return 'success'
case 'V8.DeoptimizeAllOptimizedCodeWithFunction':
return 'danger'
case 'CpuProfiler::StartProfiling':
return 'warning'
default:
return 'muted'
}
}
export const getScriptTypeClass = (scriptType: string): string => {
switch (scriptType) {
case 'Inline':
return 'warning'
case 'External':
return 'success'
case 'Bundle':
return 'primary'
case 'Minified':
return 'info'
case 'Extension':
return 'muted'
default:
return 'secondary'
}
}
export const getThirdPartyIndicator = (isThirdParty: boolean): string => {
return isThirdParty ? '🌐' : '🏠'
}
export const getStreamingIndicator = (streamed?: boolean, reason?: string): string => {
if (streamed === true) return '🚀'
if (streamed === false && reason) {
switch (reason) {
case 'inline script':
return '📄'
default:
return '⏳'
}
}
return ''
}

View File

@ -1,97 +0,0 @@
// Utility functions for formatting JavaScript performance data
export const formatDuration = (microseconds?: number): string => {
if (!microseconds || microseconds === 0) return '0ms'
const ms = microseconds / 1000
if (ms < 1) {
return `${microseconds.toFixed(0)}μs`
} else if (ms < 10) {
return `${ms.toFixed(2)}ms`
} else if (ms < 100) {
return `${ms.toFixed(1)}ms`
} else {
return `${ms.toFixed(0)}ms`
}
}
export const formatEventType = (eventName: string): string => {
switch (eventName) {
case 'EvaluateScript':
return 'Script Evaluation'
case 'v8.compile':
return 'Compilation'
case 'v8.callFunction':
return 'Function Call'
case 'v8.newInstance':
return 'Object Creation'
case 'V8.DeoptimizeAllOptimizedCodeWithFunction':
return 'Deoptimization'
case 'CpuProfiler::StartProfiling':
return 'Profiler Start'
case 'ScriptCatchup':
return 'Script Catchup'
default:
return eventName
}
}
export const getEventIcon = (eventName: string): string => {
switch (eventName) {
case 'EvaluateScript':
return '▶️'
case 'v8.compile':
return '⚙️'
case 'v8.callFunction':
return '📞'
case 'v8.newInstance':
return '🆕'
case 'V8.DeoptimizeAllOptimizedCodeWithFunction':
return '⚠️'
case 'CpuProfiler::StartProfiling':
return '📊'
case 'ScriptCatchup':
return '🔄'
default:
return '🔧'
}
}
export const truncateUrl = (url: string, maxLength: number = 80): string => {
if (!url || url.length <= maxLength) return url
// For file URLs, show the filename
if (url.includes('/')) {
const parts = url.split('/')
const filename = parts[parts.length - 1]
if (filename.length <= maxLength) {
return `.../${filename}`
}
}
// General truncation
return url.length > maxLength
? `${url.substring(0, maxLength - 3)}...`
: url
}
export const getScriptType = (event: { url?: string, isInlineScript: boolean }): string => {
if (event.isInlineScript) return 'Inline'
if (!event.url) return 'Unknown'
if (event.url.includes('.min.js')) return 'Minified'
if (event.url.includes('chunk') || event.url.includes('bundle')) return 'Bundle'
if (event.url.startsWith('chrome-extension://')) return 'Extension'
return 'External'
}
export const formatLocation = (lineNumber?: number, columnNumber?: number): string => {
if (lineNumber !== undefined && columnNumber !== undefined) {
return `${lineNumber}:${columnNumber}`
} else if (lineNumber !== undefined) {
return `Line ${lineNumber}`
}
return '-'
}

View File

@ -1,156 +0,0 @@
import type { TraceEvent } from '../../../types/trace'
import type { JavaScriptEvent, JavaScriptSummary } from '../types/javascriptEvent'
// JavaScript event names we're interested in
const JS_EVENT_NAMES = [
'EvaluateScript',
'v8.compile',
'v8.callFunction',
'v8.newInstance',
'V8.DeoptimizeAllOptimizedCodeWithFunction',
'CpuProfiler::StartProfiling',
'ScriptCatchup'
] as const
export const processJavaScriptEvents = (traceEvents: TraceEvent[]): JavaScriptEvent[] => {
const jsEvents: JavaScriptEvent[] = []
for (const event of traceEvents) {
// Filter for JavaScript-related events
if (!JS_EVENT_NAMES.includes(event.name as any)) {
continue
}
// Skip events without timing data
if (!event.dur && !event.ts) {
continue
}
const args = event.args as any
const eventId = `${event.name}-${event.ts}-${event.tid}`
// Extract URL and script information
let url = ''
let fileName = ''
let lineNumber: number | undefined
let columnNumber: number | undefined
let isInlineScript = false
if (args?.data?.url) {
url = args.data.url
fileName = args.fileName || url
isInlineScript = args.data.notStreamedReason === 'inline script' || !url.startsWith('http')
} else if (args?.fileName) {
fileName = args.fileName
url = fileName
}
if (args?.data?.lineNumber !== undefined) {
lineNumber = args.data.lineNumber
}
if (args?.data?.columnNumber !== undefined) {
columnNumber = args.data.columnNumber
}
const duration = event.dur || 0
const jsEvent: JavaScriptEvent = {
eventId,
name: event.name,
category: event.cat || '',
url,
fileName,
lineNumber,
columnNumber,
isInlineScript,
scriptId: args?.data?.scriptId?.toString(),
sampleTraceId: args?.data?.sampleTraceId?.toString(),
timing: {
startTime: event.ts,
duration,
threadDuration: event.tdur,
durationMs: duration / 1000 // Convert to milliseconds
},
v8Data: {
streamed: args?.data?.streamed,
notStreamedReason: args?.data?.notStreamedReason,
isolate: args?.data?.isolate?.toString(),
executionContextId: args?.data?.executionContextId
},
rawEvent: event
}
jsEvents.push(jsEvent)
}
// Sort by start time
return jsEvents.sort((a, b) => a.timing.startTime - b.timing.startTime)
}
export const calculateJavaScriptSummary = (events: JavaScriptEvent[]): JavaScriptSummary => {
let totalExecutionTime = 0
let totalCompilationTime = 0
let totalEvaluationTime = 0
const eventsByType = {
compilation: 0,
evaluation: 0,
execution: 0,
other: 0
}
const scriptsByUrl = new Map<string, {
url: string
events: JavaScriptEvent[]
totalTime: number
isThirdParty: boolean
}>()
for (const event of events) {
// Categorize by event type
if (event.name === 'v8.compile') {
totalCompilationTime += event.timing.duration
eventsByType.compilation++
} else if (event.name === 'EvaluateScript') {
totalEvaluationTime += event.timing.duration
eventsByType.evaluation++
} else if (event.name.includes('callFunction') || event.name.includes('newInstance')) {
totalExecutionTime += event.timing.duration
eventsByType.execution++
} else {
eventsByType.other++
}
// Group by URL/script
if (event.url) {
const url = event.isInlineScript ? '(inline scripts)' : event.url
const isThirdParty = event.url.startsWith('http') &&
!event.url.includes(window.location?.hostname || 'localhost')
if (!scriptsByUrl.has(url)) {
scriptsByUrl.set(url, {
url,
events: [],
totalTime: 0,
isThirdParty
})
}
const script = scriptsByUrl.get(url)!
script.events.push(event)
script.totalTime += event.timing.duration
}
}
return {
totalEvents: events.length,
totalExecutionTime: totalExecutionTime + totalCompilationTime + totalEvaluationTime,
totalCompilationTime,
totalEvaluationTime,
eventsByType,
scriptsByUrl
}
}

View File

@ -1,54 +0,0 @@
import type { TraceEvent } from '../../../types/trace'
export interface JavaScriptEvent {
eventId: string
name: string // EvaluateScript, v8.compile, v8.callFunction, etc.
category: string
url?: string
fileName?: string
lineNumber?: number
columnNumber?: number
isInlineScript: boolean
scriptId?: string
sampleTraceId?: string
// Timing information
timing: {
startTime: number // ts in microseconds
duration: number // dur in microseconds
threadDuration?: number // tdur in microseconds
durationMs: number // duration in milliseconds for display
}
// V8 specific data
v8Data?: {
streamed?: boolean
notStreamedReason?: string
isolate?: string
executionContextId?: number
}
// Raw trace event for debugging
rawEvent: TraceEvent
}
export interface JavaScriptSummary {
totalEvents: number
totalExecutionTime: number // in microseconds
totalCompilationTime: number // in microseconds
totalEvaluationTime: number // in microseconds
eventsByType: {
compilation: number
evaluation: number
execution: number
other: number
}
scriptsByUrl: Map<string, {
url: string
events: JavaScriptEvent[]
totalTime: number
isThirdParty: boolean
}>
}

View File

@ -1,71 +0,0 @@
/* Modal component styles using CSS variables from App.module.css */
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
padding: var(--spacing-lg);
}
.modalContainer {
background-color: var(--color-bg-secondary);
border-radius: var(--radius-lg);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
max-width: 600px;
max-height: 80vh;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
}
.modalHeader {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-bg-light);
}
.modalTitle {
margin: 0;
color: var(--color-text);
font-size: var(--font-size-xxl);
font-weight: bold;
}
.modalCloseButton {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-muted);
padding: 0;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
}
.modalCloseButton:hover {
background-color: var(--color-bg-hover);
color: var(--color-text);
}
.modalBody {
padding: var(--spacing-lg);
overflow: auto;
flex: 1;
}

View File

@ -1,5 +1,4 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import styles from './Modal.module.css'
interface ModalProps { interface ModalProps {
isOpen: boolean isOpen: boolean
@ -33,28 +32,85 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
return ( return (
<div <div
className={styles.modalOverlay} style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
padding: '20px'
}}
onClick={onClose} onClick={onClose}
> >
<div <div
className={styles.modalContainer} style={{
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
maxWidth: '600px',
maxHeight: '80vh',
width: '100%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Modal Header */} {/* Modal Header */}
<div className={styles.modalHeader}> <div
<h2 className={styles.modalTitle}> style={{
padding: '20px',
borderBottom: '1px solid #e9ecef',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#f8f9fa'
}}
>
<h2 style={{ margin: 0, color: '#495057', fontSize: '18px', fontWeight: 'bold' }}>
{title} {title}
</h2> </h2>
<button <button
onClick={onClose} onClick={onClose}
className={styles.modalCloseButton} style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: '#6c757d',
padding: '0',
width: '30px',
height: '30px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#e9ecef'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
> >
× ×
</button> </button>
</div> </div>
{/* Modal Content */} {/* Modal Content */}
<div className={styles.modalBody}> <div
style={{
padding: '20px',
overflow: 'auto',
flex: 1
}}
>
{children} {children}
</div> </div>
</div> </div>

View File

@ -1,174 +0,0 @@
/* Tooltip component styles using CSS variables from App.module.css */
.tooltipContainer {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
/* Ensure tooltip positioning context */
z-index: 1;
}
.tooltipIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background-color: var(--color-primary);
color: white;
border-radius: 50%;
font-size: var(--font-size-xs);
font-weight: bold;
cursor: help;
transition: all 0.2s ease;
user-select: none;
}
.tooltipIcon:hover {
background-color: var(--color-text);
transform: scale(1.1);
}
/* Hover tooltip */
.hoverTooltip {
position: absolute;
top: auto;
bottom: calc(100% + var(--spacing-xs));
left: 50%;
transform: translateX(-50%);
background-color: var(--color-bg-secondary);
color: var(--color-text);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
font-size: var(--font-size-sm);
z-index: var(--z-tooltip);
max-width: 300px;
word-wrap: break-word;
white-space: normal;
/* Ensure tooltip doesn't get clipped by containers */
pointer-events: none;
/* Prevent text selection */
user-select: none;
}
.hoverTooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid var(--color-bg-secondary);
/* Ensure arrow is also on top */
z-index: calc(var(--z-tooltip) + 1);
pointer-events: none;
}
/* Fixed positioned tooltip for better visibility */
.hoverTooltipFixed {
position: fixed;
transform: translateX(-50%) translateY(-100%);
background-color: var(--color-bg-secondary);
color: var(--color-text);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
font-size: var(--font-size-sm);
z-index: var(--z-tooltip);
max-width: 300px;
word-wrap: break-word;
white-space: normal;
pointer-events: none;
user-select: none;
border: 1px solid var(--color-border);
margin-top: -8px;
}
.hoverTooltipFixed::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid var(--color-bg-secondary);
z-index: calc(var(--z-tooltip) + 1);
pointer-events: none;
}
.tooltipTitle {
font-weight: bold;
margin-bottom: var(--spacing-xs);
color: var(--color-text-highlight);
}
.tooltipDescription {
margin-bottom: var(--spacing-xs);
line-height: 1.4;
}
.tooltipHint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-style: italic;
}
/* Modal content styling */
.modalContent {
line-height: 1.6;
}
.modalDescription {
margin-bottom: var(--spacing-lg);
color: var(--color-text);
}
.modalSection {
margin-bottom: var(--spacing-lg);
padding: var(--spacing-md);
background-color: var(--color-bg-light);
border-radius: var(--radius-md);
border-left: 4px solid var(--color-primary);
}
.modalSectionTitle {
font-weight: bold;
margin-bottom: var(--spacing-sm);
color: var(--color-text);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.modalSectionContent {
color: var(--color-text);
line-height: 1.5;
}
.modalLinks {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.modalLink {
color: var(--color-primary);
text-decoration: none;
display: flex;
align-items: center;
gap: var(--spacing-xs);
transition: color 0.2s ease;
}
.modalLink:hover {
color: var(--color-text-highlight);
text-decoration: underline;
}

View File

@ -2,7 +2,6 @@ import { useState } from 'react'
import { Modal } from './Modal' import { Modal } from './Modal'
import { TOOLTIP_DEFINITIONS } from './tooltipDefinitions' import { TOOLTIP_DEFINITIONS } from './tooltipDefinitions'
import type { TooltipTypeValues } from './tooltipDefinitions' import type { TooltipTypeValues } from './tooltipDefinitions'
import styles from './Tooltip.module.css'
// Tooltip component for field explanations // Tooltip component for field explanations
interface TooltipProps { interface TooltipProps {
@ -14,7 +13,6 @@ export function Tooltip({ children, type }: TooltipProps) {
const { title, description, lighthouseRelation, calculation, links } = TOOLTIP_DEFINITIONS[type] const { title, description, lighthouseRelation, calculation, links } = TOOLTIP_DEFINITIONS[type]
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 })
const handleIconClick = (e: React.MouseEvent) => { const handleIconClick = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
@ -23,51 +21,72 @@ export function Tooltip({ children, type }: TooltipProps) {
setIsModalOpen(true) setIsModalOpen(true)
} }
const handleMouseEnter = (e: React.MouseEvent) => {
const rect = (e.target as HTMLElement).getBoundingClientRect()
setTooltipPosition({
top: rect.top - 10, // Position above the icon
left: rect.left + rect.width / 2 // Center horizontally
})
setIsHovered(true)
}
return ( return (
<> <>
<div className={styles.tooltipContainer}> <div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
{children} {children}
<span <span
className={styles.tooltipIcon} onMouseEnter={() => setIsHovered(true)}
onClick={handleIconClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
onClick={handleIconClick}
style={{
marginLeft: '6px',
cursor: 'pointer',
color: '#007bff',
fontSize: '14px',
fontWeight: 'bold',
width: '16px',
height: '16px',
borderRadius: '50%',
backgroundColor: '#e3f2fd',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
transition: 'all 0.2s ease',
border: '1px solid transparent'
}}
onMouseDown={(e) => {
e.currentTarget.style.backgroundColor = '#bbdefb'
e.currentTarget.style.borderColor = '#2196f3'
}}
onMouseUp={(e) => {
e.currentTarget.style.backgroundColor = '#e3f2fd'
e.currentTarget.style.borderColor = 'transparent'
}}
> >
? ?
</span> </span>
</div>
{/* Hover tooltip - rendered as fixed positioned element */} {/* Hover tooltip - only show when not modal open */}
{isHovered && !isModalOpen && ( {isHovered && !isModalOpen && (
<div <div style={{
className={styles.hoverTooltipFixed} position: 'absolute',
style={{ top: '25px',
top: `${tooltipPosition.top}px`, left: '0',
left: `${tooltipPosition.left}px` backgroundColor: '#333',
}} color: 'white',
> padding: '8px 12px',
<div className={styles.tooltipTitle}> borderRadius: '6px',
fontSize: '12px',
lineHeight: '1.4',
maxWidth: '300px',
zIndex: 1000,
boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
whiteSpace: 'normal',
pointerEvents: 'none'
}}>
<div style={{ fontWeight: 'bold', marginBottom: '4px', color: '#4fc3f7' }}>
{title} {title}
</div> </div>
<div className={styles.tooltipDescription}> <div style={{ marginBottom: '6px' }}>
{description} {description}
</div> </div>
<div className={styles.tooltipHint}> <div style={{ fontSize: '11px', color: '#90caf9', fontStyle: 'italic' }}>
Click for detailed information Click for detailed information
</div> </div>
</div> </div>
)} )}
<div style={{ display: 'none' }}>
</div> </div>
{/* Modal with detailed content */} {/* Modal with detailed content */}
@ -76,46 +95,112 @@ export function Tooltip({ children, type }: TooltipProps) {
onClose={() => setIsModalOpen(false)} onClose={() => setIsModalOpen(false)}
title={title} title={title}
> >
<div className={styles.modalContent}> <div style={{ lineHeight: '1.6' }}>
<div className={styles.modalDescription}> <div style={{
marginBottom: '20px',
fontSize: '15px',
color: '#495057'
}}>
{description} {description}
</div> </div>
{lighthouseRelation && ( {lighthouseRelation && (
<div className={styles.modalSection}> <div style={{
<div className={styles.modalSectionTitle}> marginBottom: '20px',
padding: '15px',
backgroundColor: '#fff3e0',
borderRadius: '6px',
borderLeft: '4px solid #ffb74d'
}}>
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
color: '#e65100',
fontSize: '14px'
}}>
🎯 Lighthouse Relationship 🎯 Lighthouse Relationship
</div> </div>
<div className={styles.modalSectionContent}> <div style={{ color: '#5d4037', fontSize: '14px' }}>
{lighthouseRelation} {lighthouseRelation}
</div> </div>
</div> </div>
)} )}
{calculation && ( {calculation && (
<div className={styles.modalSection}> <div style={{
<div className={styles.modalSectionTitle}> marginBottom: '20px',
padding: '15px',
backgroundColor: '#e8f5e8',
borderRadius: '6px',
borderLeft: '4px solid #81c784'
}}>
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
color: '#2e7d32',
fontSize: '14px'
}}>
🧮 Calculation 🧮 Calculation
</div> </div>
<div className={styles.modalSectionContent}> <div style={{
color: '#1b5e20',
fontSize: '14px',
fontFamily: 'monospace',
backgroundColor: '#f1f8e9',
padding: '8px',
borderRadius: '4px',
border: '1px solid #c8e6c9'
}}>
{calculation} {calculation}
</div> </div>
</div> </div>
)} )}
{links && links.length > 0 && ( {links && links.length > 0 && (
<div className={styles.modalSection}> <div style={{
<div className={styles.modalSectionTitle}> padding: '15px',
backgroundColor: '#f3e5f5',
borderRadius: '6px',
borderLeft: '4px solid #ba68c8'
}}>
<div style={{
fontWeight: 'bold',
marginBottom: '12px',
color: '#6a1b9a',
fontSize: '14px'
}}>
📚 Learn More 📚 Learn More
</div> </div>
<div className={styles.modalLinks}> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{links.map((link, index) => ( {links.map((link, index) => (
<a <a
key={index} key={index}
href={link.url} href={link.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={styles.modalLink} style={{
color: '#1976d2',
textDecoration: 'none',
fontSize: '14px',
padding: '8px 12px',
backgroundColor: 'white',
borderRadius: '4px',
border: '1px solid #e3f2fd',
transition: 'all 0.2s ease',
display: 'inline-block'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#e3f2fd'
e.currentTarget.style.borderColor = '#2196f3'
e.currentTarget.style.transform = 'translateY(-1px)'
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'white'
e.currentTarget.style.borderColor = '#e3f2fd'
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
> >
🔗 {link.text} 🔗 {link.text}
</a> </a>

View File

@ -12,8 +12,6 @@ export const TooltipType = {
CONNECTION_TIME: 'CONNECTION_TIME', CONNECTION_TIME: 'CONNECTION_TIME',
SERVER_LATENCY: 'SERVER_LATENCY', SERVER_LATENCY: 'SERVER_LATENCY',
REQUEST_URL: 'REQUEST_URL', REQUEST_URL: 'REQUEST_URL',
CONNECTION_NUMBER: 'CONNECTION_NUMBER',
REQUEST_NUMBER: 'REQUEST_NUMBER',
REQUEST_DURATION: 'REQUEST_DURATION', REQUEST_DURATION: 'REQUEST_DURATION',
DATA_RATE: 'DATA_RATE', DATA_RATE: 'DATA_RATE',
TOTAL_RESPONSE_TIME: 'TOTAL_RESPONSE_TIME', TOTAL_RESPONSE_TIME: 'TOTAL_RESPONSE_TIME',
@ -87,7 +85,7 @@ export const TOOLTIP_DEFINITIONS: Record<TooltipTypeValues, TooltipDefinition> =
[TooltipType.REQUEST_PRIORITY]: { [TooltipType.REQUEST_PRIORITY]: {
title: "Request Priority", title: "Request Priority",
description: "Browser's internal priority for this request. Icons: 🔥 VeryHigh, 🔺 High, 🟡 Medium, 🔹 Low, 🐢 VeryLow. Determines resource loading order.", description: "Browser's internal priority for this request: VeryHigh, High, Medium, Low, VeryLow. Determines resource loading order.",
lighthouseRelation: "High priority resources are critical for LCP and FCP. Low priority resources should not block critical content.", lighthouseRelation: "High priority resources are critical for LCP and FCP. Low priority resources should not block critical content.",
links: [ links: [
{ text: "Resource Prioritization", url: "https://web.dev/articles/resource-prioritization" }, { text: "Resource Prioritization", url: "https://web.dev/articles/resource-prioritization" },
@ -157,27 +155,6 @@ export const TOOLTIP_DEFINITIONS: Record<TooltipTypeValues, TooltipDefinition> =
] ]
}, },
[TooltipType.CONNECTION_NUMBER]: {
title: "Connection #",
description: "Sequential number assigned to each unique network connection based on first-seen order. HTTP/1.1 typically allows 6 connections per domain, HTTP/2 uses 1 connection with multiplexing.",
lighthouseRelation: "Connection limits can create bottlenecks in HTTP/1.1. HTTP/2 reduces this with multiplexing. HTTP/3 uses QUIC streams instead of traditional connections.",
calculation: "Based on Chrome's internal connectionId from trace data. For HTTP/3, this represents QUIC stream groupings rather than actual TCP connections.",
links: [
{ text: "HTTP Connection Management", url: "https://web.dev/articles/http-connection-management" },
{ text: "HTTP/3 and QUIC", url: "https://web.dev/articles/http3" }
]
},
[TooltipType.REQUEST_NUMBER]: {
title: "Request #",
description: "Sequential number of this request within its connection/stream group. Shows request ordering for each unique connection ID.",
lighthouseRelation: "Request ordering affects resource loading priority. In HTTP/1.1, earlier requests block later ones. HTTP/2/3 allow parallel processing.",
calculation: "Incremental counter starting from 1 for each connection ID, ordered by request start time. For HTTP/3, represents ordering within QUIC stream groups.",
links: [
{ text: "HTTP/2 Multiplexing", url: "https://web.dev/articles/http2" }
]
},
[TooltipType.REQUEST_DURATION]: { [TooltipType.REQUEST_DURATION]: {
title: "Request Duration", title: "Request Duration",
description: "Client-side time including queuing, network setup, and download. This is Total Response Time minus Server Latency.", description: "Client-side time including queuing, network setup, and download. This is Total Response Time minus Server Latency.",

View File

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