Compare commits

...

18 Commits

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 09:08:14 -05:00
1035b28472 Reorganize RequestBreakdown components into dedicated directory
Move RequestBreakdown.tsx and module CSS into RequestBreakdown/ directory:
- RequestBreakdown.tsx → RequestBreakdown/index.tsx
- RequestBreakdown.module.css → RequestBreakdown/RequestBreakdown.module.css
- Update all import paths to work with new directory structure
- Maintain existing functionality with cleaner organization

All RequestBreakdown-related components now co-located for better maintainability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 08:33:34 -05:00
52543a5d04 Refactor Request Breakdown into modular components
Extract breakdown sections into separate components for better maintainability:
- ResourceTypeBreakdown: handles resource type analysis and visualization
- StatusCodeBreakdown: handles status code analysis and visualization
- HostnameBreakdown: handles hostname analysis with toggle functionality
- RequestDataSummary: statistics cards component (previously extracted)
- BreakdownTableHeader: shared table header component (previously extracted)

Main RequestBreakdown component reduced from ~420 to ~90 lines with improved separation of concerns.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 08:29:03 -05:00
a2e161bf2a Improve Request Breakdown UI with better contrast and text handling
Enhances the visual presentation and usability of the Request Breakdown page with improved typography and column constraints.

Changes:
- Update header colors (h2, h3) from #333 to white for better contrast against dark backgrounds
- Fix category column text wrapping with intelligent word breaking and proper line height
- Add table layout constraints to prevent horizontal overflow
- Improve mobile typography with responsive font sizing for category names
- Add hyphenation support for long hostnames and domain names

Benefits:
- Better accessibility and readability with higher contrast headers
- Prevents table layout issues with long hostnames
- Maintains responsive design across all screen sizes
- Professional appearance with consistent text handling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 08:15:38 -05:00
c04735b1b9 Add hostname toggle functionality to Request Breakdown
Adds the ability to expand the hostname breakdown section from the default top 10 to show all hostnames, improving usability for traces with many different domains.

Features:
- Default view shows top 10 hostnames for better performance and readability
- Toggle button to expand/collapse between "top 10" and "all hostnames" views
- Dynamic status text indicating current view and total hostname count
- Styled toggle button matching the page gradient theme with hover effects
- Responsive design with mobile-friendly stacked layout
- State management to preserve user's view preference during session

Benefits:
- Faster initial load by limiting displayed rows
- On-demand access to complete hostname data when needed
- Better UX for traces with many hostnames while maintaining performance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 08:07:31 -05:00
d50ceb2a37 Update progress bar colors to match statistics card gradient
Changes progress bar fill color from green gradient to the purple-blue gradient used in the overview statistics cards for consistent visual design across the Request Breakdown page.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 08:03:07 -05:00
b3dedb3dbf Add Size % and Response Time % columns to Request Breakdown
Enhances the breakdown tables with comprehensive percentage analysis across three dimensions: request count, data size, and response time.

Features:
- Size Percentage column showing data transfer distribution
- Response Time Percentage column showing performance impact distribution
- Visual progress bars for all percentage columns (Count %, Size %, Response Time %)
- Updated grid layout to accommodate 8-column structure
- Responsive design updates for mobile compatibility
- Clear column headers with abbreviated labels for better readability

This enables three-dimensional analysis to identify:
- Categories with high request counts but low data usage
- Categories consuming disproportionate bandwidth
- Performance bottlenecks with high cumulative response times
- Optimization opportunities across all metrics

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 08:01:04 -05:00
33fb2b1674 Add Request Breakdown page with comprehensive data analysis
Introduces new breakdown page that provides detailed statistics and visualizations for HTTP request data analysis across multiple dimensions.

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 07:52:22 -05:00
327ef29d55 Fix CSV export timing columns and add units to headers
Corrects CSV export to properly include timing data by accessing nested timing properties and calculating derived values like DNS time, connection time, and data rate. Headers now include units (μs, bytes, bytes/sec) for clarity.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 07:18:39 -05:00
a6a7bbb65b Add CSV export functionality to HTTP requests table
Enables users to download filtered HTTP request data as CSV files with comprehensive data including timing, sizes, CDN analysis, and queue analysis.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 06:43:54 -05:00
488d9a2650 Add JavaScript viewer and HTTP request initiator tracking
Introduces comprehensive request initiator visualization and JavaScript performance analysis capabilities.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 06:32:57 -05:00
8075e54397 Fix RequestRowDetails styling to use foreground colors only for status indicators
- Replace getTotalResponseTimeColor() inline styles with getTotalResponseTimeClass() CSS classes
- Add CSS overrides in RequestRowDetails.module.css to remove background colors from status classes
- Ensure danger, warning, and success indicators only affect text color, not background
- Maintain consistent component background styling throughout expanded details
- Use \!important declarations to override inherited status class background properties
- Preserve semantic color meanings (green=success, orange=warning, red=danger) for text only

Technical changes:
- Import getTotalResponseTimeClass instead of getTotalResponseTimeColor in RequestRowDetails.tsx
- Add CSS rules targeting .detailListItem and .timingHighlighted with status classes
- Set background-color: transparent \!important for all status class combinations
- Apply color variables (--color-success, --color-warning, --color-danger) to text only

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-12 11:42:02 -05:00
f9abfbc8ff Add comprehensive column visibility controls to HTTP requests table
- Create ColumnSettings component with collapsible panel and organized column groups
- Implement localStorage persistence for column visibility preferences across sessions
- Add default visible columns: expand, url, start time, total response time, data rate, content-length
- Group columns by category: Basic, Connection, Timing, Performance, Advanced
- Provide bulk actions: Show All, Hide All, Reset to Defaults
- Add conditional rendering for all table headers and cells based on visibility state
- Update RequestRowDetails to dynamically calculate colSpan based on visible columns
- Create responsive grid layout for column settings with hover effects
- Use CSS modules with App.css variables for consistent theming
- Implement type-safe column management with proper TypeScript interfaces

Features:
- 🎛️ Gear icon toggle button for easy access to column settings
- 📁 Logical grouping of related columns for better organization
- 💾 Automatic persistence of user preferences in localStorage
- 🎯 Clean default view showing only essential columns
- 🔧 Flexible customization allowing users to show exactly what they need
- 📱 Responsive design that adapts to different screen sizes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-12 09:46:14 -05:00
33cafe695c Add connection number and request number columns to HTTP requests table
- Extract connectionId from Chrome DevTools trace data in ResourceSendRequest and ResourceReceiveResponse events
- Create connectionUtils to assign sequential connection numbers (1, 2, 3...) based on chronological first-seen order
- Add Connection # and Request # columns between URL and Start Time in requests table
- Implement request numbering within each connection ordered by start time
- Add comprehensive tooltips explaining connection behavior across HTTP versions:
  * HTTP/1.1: Traditional connections with 6-connection limit per domain
  * HTTP/2: Single connection with multiplexing capability
  * HTTP/3: QUIC stream groupings instead of TCP connections
- Update HTTPRequest interface with connectionId, connectionNumber, and requestNumberOnConnection fields
- Integrate connection processing into HTTPRequestViewer pipeline
- Update RequestRowDetails colSpan to accommodate new columns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-12 09:13:13 -05:00
fdccd59e04 Fix RequestRowDetails hover state to prevent lighten effect inheritance
- Add CSS rules to disable filter brightness effects on expanded request rows
- Prevent RequestRowDetails from inheriting tbody tr:hover lightening behavior
- Maintain intended background color and remove unwanted border changes on hover
- Apply hover overrides to both row and cell elements for complete coverage
- Ensure expanded row details maintain consistent appearance during interaction

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-12 08:56:07 -05:00
357733fd15 Enhance navigation with prominent styling and CSS module architecture
- Add comprehensive header layout with appHeader, headerLeft, and backButton classes
- Implement prominent navigation buttons with hover effects and active state indicators
- Add app title with proper typography styling using CSS variables
- Create navButton class with blue primary color scheme, transitions, and shadow effects
- Add active state visual feedback with bottom border triangle indicator
- Use CSS variables from App.css for consistent theming and maintainability
- Improve responsive layout with flexbox positioning and proper spacing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-12 08:51:15 -05:00
9f41ff72f0 Convert components to CSS modules and fix tooltip positioning
- Convert Tooltip and Modal components from inline styles to CSS modules
- Add centralized z-index management with CSS variables (--z-modal: 10000, --z-tooltip: 50000)
- Implement fixed positioning for tooltips with dynamic coordinate calculation
- Fix header row hover effects to not inherit data row brightness filters
- Add proper table container positioning context for tooltips
- Create RequestsTable.module.css for table-specific styling
- Ensure tooltips are always visible above all content with proper arrow positioning

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-12 08:43:58 -05:00
35537b8a5b Add priority icons and prevent column wrapping
- Add getPriorityIcon() function with visual priority indicators:
  🔥 VeryHigh, 🔺 High, 🟡 Medium, 🔹 Low, 🐢 VeryLow
- Display priority icons in both table rows and detail view
- Add priorityCell CSS class with fixed width and no-wrap styling
- Update tooltip to show icon legend for all priority levels
- Enhance table hover effects with brightness filter and dashed borders

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-12 08:23:42 -05:00
46 changed files with 249616 additions and 398 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
- **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
-
- **CSS**: Use CSS modules for styling components, avoid inline styles for better performance and maintainability

244498
examples/chek-plp.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -49,10 +49,105 @@
--radius-xxl: 12px;
/* Z-index */
--z-modal: 1000;
--z-modal: 10000;
--z-tooltip: 50000;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
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,13 +3,15 @@ import styles from './App.module.css'
import TraceViewer from './components/TraceViewer'
import PhaseViewer from './components/PhaseViewer'
import HTTPRequestViewer from './components/httprequestviewer/HTTPRequestViewer'
import JavaScriptViewer from './components/javascriptviewer/JavaScriptViewer'
import RequestBreakdown from './components/RequestBreakdown'
import RequestDebugger from './components/RequestDebugger'
import TraceUpload from './components/TraceUpload'
import TraceSelector from './components/TraceSelector'
import { traceDatabase } from './utils/traceDatabase'
import { useDatabaseTraceData } from './hooks/useDatabaseTraceData'
type AppView = 'trace' | 'phases' | 'http' | 'debug'
type AppView = 'trace' | 'phases' | 'http' | 'js' | 'breakdown' | 'debug'
type AppMode = 'selector' | 'upload' | 'analysis'
type ThreeDView = 'network' | 'timeline' | null
@ -33,7 +35,7 @@ const getUrlParams = () => {
const view = segments[1] as AppView
const threeDView = segments[2] as ThreeDView
// Validate view and 3D view values
const validViews: AppView[] = ['trace', 'phases', 'http', 'debug']
const validViews: AppView[] = ['trace', 'phases', 'http', 'js', 'breakdown', 'debug']
const validThreeDViews: (ThreeDView)[] = ['network', 'timeline']
const validatedView = validViews.includes(view) ? view : 'http'
const validatedThreeDView = validThreeDViews.includes(threeDView) ? threeDView : null
@ -42,7 +44,7 @@ const getUrlParams = () => {
const traceId = segments[0]
const view = segments[1] as AppView
// Validate view is one of the allowed values
const validViews: AppView[] = ['trace', 'phases', 'http', 'debug']
const validViews: AppView[] = ['trace', 'phases', 'http', 'js', 'breakdown', 'debug']
const validatedView = validViews.includes(view) ? view : 'http'
return { traceId, view: validatedView, threeDView: null }
} else if (segments.length === 1) {
@ -181,18 +183,19 @@ function App() {
return (
<>
<div className={styles.mainApp}>
<div>
<div>
<div className={styles.appHeader}>
<div className={styles.headerLeft}>
<button
className={styles.backButton}
onClick={handleBackToSelector}
>
Back to Traces
</button>
<h1>Perf Viz</h1>
<h1 className={styles.appTitle}>Performance Trace Analysis</h1>
</div>
<nav>
<nav className={styles.nav}>
<button
className={`${styles.navButton} ${currentView === 'trace' ? styles.active : ''}`}
onClick={() => {
setCurrentView('trace')
updateUrlWithTraceId(selectedTraceId, 'trace', null)
@ -201,6 +204,7 @@ function App() {
Trace Stats
</button>
<button
className={`${styles.navButton} ${currentView === 'phases' ? styles.active : ''}`}
onClick={() => {
setCurrentView('phases')
updateUrlWithTraceId(selectedTraceId, 'phases', null)
@ -209,6 +213,7 @@ function App() {
Phase Events
</button>
<button
className={`${styles.navButton} ${currentView === 'http' ? styles.active : ''}`}
onClick={() => {
setCurrentView('http')
updateUrlWithTraceId(selectedTraceId, 'http', null)
@ -217,6 +222,25 @@ function App() {
HTTP Requests
</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={() => {
setCurrentView('debug')
updateUrlWithTraceId(selectedTraceId, 'debug', null)
@ -239,6 +263,14 @@ function App() {
<HTTPRequestViewer traceId={selectedTraceId} />
)}
{currentView === 'js' && (
<JavaScriptViewer traceId={selectedTraceId} />
)}
{currentView === 'breakdown' && (
<RequestBreakdown traceId={selectedTraceId} />
)}
{currentView === 'debug' && traceData && (
<RequestDebugger traceEvents={traceData.traceEvents} />
)}

View File

@ -0,0 +1,123 @@
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

@ -0,0 +1,219 @@
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

@ -0,0 +1,334 @@
.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

@ -0,0 +1,57 @@
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

@ -0,0 +1,192 @@
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

@ -0,0 +1,192 @@
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

@ -0,0 +1,120 @@
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

@ -0,0 +1,125 @@
/* 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

@ -0,0 +1,122 @@
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

@ -0,0 +1,129 @@
.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,6 +3,7 @@ import { useDatabaseTraceData } from '../../hooks/useDatabaseTraceData'
import { getUrlParams, updateUrlWithTraceId } from '../../App'
import RequestFilters from './RequestFilters'
import RequestsTable from './RequestsTable'
import ColumnSettings from './ColumnSettings'
import styles from './HTTPRequestViewer.module.css'
// Lazy load 3D viewers to reduce main bundle size
@ -50,12 +51,14 @@ import { extractScreenshots, findUniqueScreenshots } from './lib/screenshotUtils
import { processHTTPRequests } from './lib/httpRequestProcessor'
import { analyzeCDN, analyzeQueueReason } from './lib/analysisUtils'
import { addRequestPostProcessing } from './lib/requestPostProcessor'
import { assignConnectionNumbers } from './lib/connectionUtils'
// Import types
import type { HTTPRequest, ScreenshotEvent } from './types/httpRequest'
import HTTPRequestLoading from "./HTTPRequestLoading.tsx";
import sortRequests from "./lib/sortRequests.ts";
import PaginationControls from "./PaginationControls.tsx";
import { exportRequestsToCSV, downloadCSV } from './lib/csvExport';
interface HTTPRequestViewerProps {
@ -75,6 +78,43 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
// 3D viewer state - initialized from URL
const [show3DViewer, setShow3DViewer] = 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
useEffect(() => {
@ -116,12 +156,69 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
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(() => {
if (!traceData) return []
const httpRequests = processHTTPRequests(traceData.traceEvents)
const sortedRequests = sortRequests(httpRequests)
const processedRequests = addRequestPostProcessing(sortedRequests, analyzeQueueReason, analyzeCDN)
return processedRequests
const requestsWithConnections = assignConnectionNumbers(processedRequests)
return requestsWithConnections
}, [traceData])
// Extract and process screenshots with SSIM analysis
@ -286,6 +383,15 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
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) {
return (
@ -343,6 +449,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
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} />
{/* 3D Network Visualization Modal */}
@ -427,6 +544,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
</div>
)}
{/* Column Settings */}
<ColumnSettings
visibleColumns={visibleColumns}
onColumnToggle={handleColumnToggle}
isOpen={columnSettingsOpen}
onToggle={() => setColumnSettingsOpen(!columnSettingsOpen)}
onShowAll={handleShowAllColumns}
onHideAll={handleHideAllColumns}
onResetDefaults={handleResetDefaults}
/>
{/* Requests Table */}
<RequestsTable
httpRequests={httpRequests}
@ -436,6 +564,7 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
showQueueAnalysis={showQueueAnalysis}
expandedRows={expandedRows}
onToggleRowExpansion={toggleRowExpansion}
visibleColumns={visibleColumns}
/>
</div>
)

View File

@ -0,0 +1,211 @@
/* 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

@ -0,0 +1,189 @@
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

@ -0,0 +1,256 @@
/* 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

@ -0,0 +1,158 @@
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,6 +8,18 @@ tr {
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 {
margin: 0 32px;
border: 1px solid #6c757d;
@ -98,4 +110,30 @@ div.fullWidth {
text-overflow: ellipsis;
cursor: help;
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,24 +1,29 @@
import React from 'react'
import type { HTTPRequest } from './types/httpRequest'
import InitiatorView from './InitiatorView'
import styles from './RequestRowDetails.module.css'
// Import utility functions
import { formatDuration, formatSize } from './lib/formatUtils'
import {
getTotalResponseTimeColor,
getTotalResponseTimeClass,
getQueueAnalysisIcon,
getCDNIcon,
getCDNDisplayName
getCDNDisplayName,
getPriorityIcon
} from './lib/colorUtils'
interface RequestRowDetailsProps {
request: HTTPRequest
visibleColumns: Record<string, boolean>
}
const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request }) => {
const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request, visibleColumns }) => {
// Calculate the number of visible columns for colSpan
const visibleColumnCount = Object.values(visibleColumns).filter(Boolean).length
return (
<tr key={`${request.requestId}-expanded`} className={styles.expandedRow}>
<td colSpan={18}>
<td colSpan={visibleColumnCount}>
<div className={styles.expandedContent}>
{/* Request Details */}
@ -27,7 +32,12 @@ const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request }) => {
<div className={styles.detailList}>
<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>Priority:</strong> {request.priority}</div>
<div className={styles.detailListItem}>
<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>Content-Length:</strong> {request.contentLength ? formatSize(request.contentLength) : '-'}</div>
<div className={styles.detailListItem}><strong>From Cache:</strong> {request.fromCache ? 'Yes' : 'No'}</div>
@ -35,6 +45,9 @@ const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request }) => {
</div>
</div>
{/* Initiator Information */}
<InitiatorView request={request} />
{/* Network Timing */}
<div className={styles.detailCard}>
<h4 className={styles.detailCardTitle}>Network Timing</h4>
@ -50,7 +63,7 @@ const RequestRowDetails: React.FC<RequestRowDetailsProps> = ({ request }) => {
</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} ${styles.timingHighlighted}`} style={{ ...getTotalResponseTimeColor(request.timing.totalResponseTime) }}>
<div className={`${styles.detailListItem} ${styles.timingHighlighted} ${getTotalResponseTimeClass(request.timing.totalResponseTime)}`}>
<strong>Total Response Time:</strong> {formatDuration(request.timing.totalResponseTime)}
</div>
<div className={styles.detailListItem}><strong>Network Duration Only:</strong> {formatDuration(request.timing.networkDuration)}</div>

View File

@ -19,11 +19,35 @@
td {
padding: 2px 8px;
border-radius: var(--radius-md);
border: 1px solid var(--color-bg);
}
tr {
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 {
color: var(--color-text);
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,7 +13,10 @@ import {
getSizeClass,
getQueueAnalysisIcon,
getCDNIcon,
getCDNDisplayName, getProtocolClass, getConnectionClass
getCDNDisplayName,
getPriorityIcon,
getProtocolClass,
getConnectionClass
} from './lib/colorUtils'
import { truncateUrl } from './lib/urlUtils'
@ -22,116 +25,174 @@ interface RequestRowSummaryProps {
showQueueAnalysis: boolean
isExpanded: boolean
onToggleRowExpansion: (requestId: string) => void
visibleColumns: Record<string, boolean>
}
const RequestRowSummary: React.FC<RequestRowSummaryProps> = ({
request,
showQueueAnalysis,
isExpanded,
onToggleRowExpansion
onToggleRowExpansion,
visibleColumns
}) => {
return (
<>
<tr className={styles.default} key={request.requestId}
onClick={() => onToggleRowExpansion(request.requestId)}
>
<td>
{isExpanded ? '' : '+'}
</td>
<td>
{request.method}
</td>
<td>
{request.statusCode || '-'}
</td>
<td>
{request.resourceType}
</td>
<td>
{request.priority || '-'}
</td>
<td>
<a href={request.url} target="_blank" rel="noopener noreferrer">
{truncateUrl(request.url)}
</a>
</td>
<td>
{formatDuration(request.timing.startOffset)}
</td>
<td className={getConnectionClass(request?.timing?.queueTime ||0)}>
<div>
{formatDuration(request.timing.queueTime)}
{showQueueAnalysis && request.queueAnalysis && (
<span
title={request.queueAnalysis.description}
>
{getQueueAnalysisIcon(request.queueAnalysis)}
</span>
)}
</div>
</td>
{visibleColumns.expand && (
<td>
{isExpanded ? '' : '+'}
</td>
)}
{visibleColumns.method && (
<td>
{request.method}
</td>
)}
{visibleColumns.status && (
<td>
{request.statusCode || '-'}
</td>
)}
{visibleColumns.type && (
<td>
{request.resourceType}
</td>
)}
{visibleColumns.priority && (
<td className={styles.priorityCell}>
<span>
{getPriorityIcon(request.priority)}
{request.priority || '-'}
</span>
</td>
)}
{visibleColumns.url && (
<td>
<a href={request.url} target="_blank" rel="noopener noreferrer">
{truncateUrl(request.url)}
</a>
</td>
)}
{visibleColumns.connectionNumber && (
<td>
{request.connectionNumber || '-'}
</td>
)}
{visibleColumns.requestNumber && (
<td>
{request.requestNumberOnConnection || '-'}
</td>
)}
{visibleColumns.startTime && (
<td>
{formatDuration(request.timing.startOffset)}
</td>
)}
{visibleColumns.queueTime && (
<td className={getConnectionClass(request?.timing?.queueTime ||0)}>
<div>
{formatDuration(request.timing.queueTime)}
{showQueueAnalysis && request.queueAnalysis && (
<span
title={request.queueAnalysis.description}
>
{getQueueAnalysisIcon(request.queueAnalysis)}
</span>
)}
</div>
</td>
)}
{/* DNS Time */}
<td >
{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))
: <span>cached</span>
}
</td>
{visibleColumns.dns && (
<td >
{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))
: <span>cached</span>
}
</td>
)}
{/* Connection Time */}
<td className={getConnectionClass((request.timing.connectEnd || 0) - (request.timing.connectStart || 0))}>
{request.timing.connectStart !== undefined && request.timing.connectEnd !== undefined
&& request.timing.connectStart >= 0 && request.timing.connectEnd >= 0
? <span >
{formatDuration((request.timing.connectEnd || 0) - (request.timing.connectStart || 0))}
</span>
: <span>
{request.connectionReused ? 'reused' : 'cached'}
</span>
}
</td>
<td className={`${getServerLatencyClass(request.timing.serverLatency)}`}>
{formatDuration(request.timing.serverLatency)}
</td>
{visibleColumns.connection && (
<td className={getConnectionClass((request.timing.connectEnd || 0) - (request.timing.connectStart || 0))}>
{request.timing.connectStart !== undefined && request.timing.connectEnd !== undefined
&& request.timing.connectStart >= 0 && request.timing.connectEnd >= 0
? <span >
{formatDuration((request.timing.connectEnd || 0) - (request.timing.connectStart || 0))}
</span>
: <span>
{request.connectionReused ? 'reused' : 'cached'}
</span>
}
</td>
)}
<td className={getDurationClass(request.timing.duration)}>
{formatDuration(request.timing.duration)}
</td>
{visibleColumns.serverLatency && (
<td className={`${getServerLatencyClass(request.timing.serverLatency)}`}>
{formatDuration(request.timing.serverLatency)}
</td>
)}
{visibleColumns.duration && (
<td className={getDurationClass(request.timing.duration)}>
{formatDuration(request.timing.duration)}
</td>
)}
{/* Total Response Time */}
<td className={`${getTotalResponseTimeClass(request.timing.totalResponseTime)}`}>
{formatDuration(request.timing.totalResponseTime)}
</td>
{visibleColumns.totalResponseTime && (
<td className={`${getTotalResponseTimeClass(request.timing.totalResponseTime)}`}>
{formatDuration(request.timing.totalResponseTime)}
</td>
)}
{/* Data Rate */}
<td className={`${getDataRateClass(request.encodedDataLength, request.contentLength, request.timing.duration)}`}>
{formatDataRate(request.encodedDataLength, request.contentLength, request.timing.duration)}
</td>
{visibleColumns.dataRate && (
<td className={`${getDataRateClass(request.encodedDataLength, request.contentLength, request.timing.duration)}`}>
{formatDataRate(request.encodedDataLength, request.contentLength, request.timing.duration)}
</td>
)}
<td className={`${getSizeClass(request.encodedDataLength)}`}>
{formatSize(request.encodedDataLength)}
</td>
<td className={`${getSizeClass(request.contentLength)}`}>
{request.contentLength ? formatSize(request.contentLength) : '-'}
</td>
<td className={getProtocolClass(request.protocol)}>
{request.protocol || '-'}
</td>
<td
title={request.cdnAnalysis ?
`${getCDNDisplayName(request.cdnAnalysis.provider)} ${request.cdnAnalysis.isEdge ? '(Edge)' : '(Origin)'} - ${request.cdnAnalysis.detectionMethod}` :
'No CDN detected'}
>
{request.cdnAnalysis ? getCDNIcon(request.cdnAnalysis) : '-'}
</td>
<td>
{request.fromCache ? '💾' : request.connectionReused ? '🔄' : '🌐'}
</td>
{visibleColumns.size && (
<td className={`${getSizeClass(request.encodedDataLength)}`}>
{formatSize(request.encodedDataLength)}
</td>
)}
{visibleColumns.contentLength && (
<td className={`${getSizeClass(request.contentLength)}`}>
{request.contentLength ? formatSize(request.contentLength) : '-'}
</td>
)}
{visibleColumns.protocol && (
<td className={getProtocolClass(request.protocol)}>
{request.protocol || '-'}
</td>
)}
{visibleColumns.cdn && (
<td
title={request.cdnAnalysis ?
`${getCDNDisplayName(request.cdnAnalysis.provider)} ${request.cdnAnalysis.isEdge ? '(Edge)' : '(Origin)'} - ${request.cdnAnalysis.detectionMethod}` :
'No CDN detected'}
>
{request.cdnAnalysis ? getCDNIcon(request.cdnAnalysis) : '-'}
</td>
)}
{visibleColumns.cache && (
<td>
{request.fromCache ? '💾' : request.connectionReused ? '🔄' : '🌐'}
</td>
)}
</tr>
{/* Expanded Row Details */}
{isExpanded && <RequestRowDetails request={request} />}
{isExpanded && <RequestRowDetails request={request} visibleColumns={visibleColumns} />}
</>
)
}

View File

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

View File

@ -165,4 +165,18 @@ export const getCDNDisplayName = (provider: CDNAnalysis['provider']): string =>
case 'keycdn': return 'KeyCDN'
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

@ -0,0 +1,67 @@
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

@ -0,0 +1,206 @@
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,6 +43,24 @@ export const processHTTPRequests = (traceEvents: TraceEvent[]): HTTPRequest[] =>
request.resourceType = args.data.resourceType || ''
request.priority = args.data.priority || ''
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
case 'ResourceReceiveResponse':
@ -52,6 +70,11 @@ export const processHTTPRequests = (traceEvents: TraceEvent[]): HTTPRequest[] =>
request.protocol = args.data.protocol
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
if (request.responseHeaders) {
const contentLengthHeader = request.responseHeaders.find(

View File

@ -23,6 +23,27 @@ export interface ScreenshotEvent {
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 {
requestId: string
url: string
@ -30,6 +51,9 @@ export interface HTTPRequest {
method: string
resourceType: string
priority: string
connectionId?: number
connectionNumber?: number
requestNumberOnConnection?: number
statusCode?: number
mimeType?: string
protocol?: string
@ -68,4 +92,5 @@ export interface HTTPRequest {
connectionReused: boolean
queueAnalysis?: QueueAnalysis
cdnAnalysis?: CDNAnalysis
initiator?: RequestInitiator
}

View File

@ -0,0 +1,184 @@
/* 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

@ -0,0 +1,147 @@
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

@ -0,0 +1,182 @@
/* 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

@ -0,0 +1,111 @@
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

@ -0,0 +1,155 @@
/* 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

@ -0,0 +1,160 @@
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

@ -0,0 +1,64 @@
// 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

@ -0,0 +1,97 @@
// 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

@ -0,0 +1,156 @@
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

@ -0,0 +1,54 @@
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

@ -0,0 +1,71 @@
/* 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,4 +1,5 @@
import { useEffect } from 'react'
import styles from './Modal.module.css'
interface ModalProps {
isOpen: boolean
@ -32,85 +33,28 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
return (
<div
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'
}}
className={styles.modalOverlay}
onClick={onClose}
>
<div
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'
}}
className={styles.modalContainer}
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div
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' }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{title}
</h2>
<button
onClick={onClose}
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'
}}
className={styles.modalCloseButton}
>
×
</button>
</div>
{/* Modal Content */}
<div
style={{
padding: '20px',
overflow: 'auto',
flex: 1
}}
>
<div className={styles.modalBody}>
{children}
</div>
</div>

View File

@ -0,0 +1,174 @@
/* 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,6 +2,7 @@ import { useState } from 'react'
import { Modal } from './Modal'
import { TOOLTIP_DEFINITIONS } from './tooltipDefinitions'
import type { TooltipTypeValues } from './tooltipDefinitions'
import styles from './Tooltip.module.css'
// Tooltip component for field explanations
interface TooltipProps {
@ -13,6 +14,7 @@ export function Tooltip({ children, type }: TooltipProps) {
const { title, description, lighthouseRelation, calculation, links } = TOOLTIP_DEFINITIONS[type]
const [isHovered, setIsHovered] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 })
const handleIconClick = (e: React.MouseEvent) => {
e.preventDefault()
@ -21,72 +23,51 @@ export function Tooltip({ children, type }: TooltipProps) {
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 (
<>
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
<div className={styles.tooltipContainer}>
{children}
<span
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={styles.tooltipIcon}
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'
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsHovered(false)}
>
?
</span>
{/* Hover tooltip - only show when not modal open */}
{isHovered && !isModalOpen && (
<div style={{
position: 'absolute',
top: '25px',
left: '0',
backgroundColor: '#333',
color: 'white',
padding: '8px 12px',
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}
</div>
<div style={{ marginBottom: '6px' }}>
{description}
</div>
<div style={{ fontSize: '11px', color: '#90caf9', fontStyle: 'italic' }}>
Click for detailed information
</div>
</div>
{/* Hover tooltip - rendered as fixed positioned element */}
{isHovered && !isModalOpen && (
<div
className={styles.hoverTooltipFixed}
style={{
top: `${tooltipPosition.top}px`,
left: `${tooltipPosition.left}px`
}}
>
<div className={styles.tooltipTitle}>
{title}
</div>
)}
<div className={styles.tooltipDescription}>
{description}
</div>
<div className={styles.tooltipHint}>
Click for detailed information
</div>
</div>
)}
<div style={{ display: 'none' }}>
</div>
{/* Modal with detailed content */}
@ -95,112 +76,46 @@ export function Tooltip({ children, type }: TooltipProps) {
onClose={() => setIsModalOpen(false)}
title={title}
>
<div style={{ lineHeight: '1.6' }}>
<div style={{
marginBottom: '20px',
fontSize: '15px',
color: '#495057'
}}>
<div className={styles.modalContent}>
<div className={styles.modalDescription}>
{description}
</div>
{lighthouseRelation && (
<div style={{
marginBottom: '20px',
padding: '15px',
backgroundColor: '#fff3e0',
borderRadius: '6px',
borderLeft: '4px solid #ffb74d'
}}>
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
color: '#e65100',
fontSize: '14px'
}}>
<div className={styles.modalSection}>
<div className={styles.modalSectionTitle}>
🎯 Lighthouse Relationship
</div>
<div style={{ color: '#5d4037', fontSize: '14px' }}>
<div className={styles.modalSectionContent}>
{lighthouseRelation}
</div>
</div>
)}
{calculation && (
<div style={{
marginBottom: '20px',
padding: '15px',
backgroundColor: '#e8f5e8',
borderRadius: '6px',
borderLeft: '4px solid #81c784'
}}>
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
color: '#2e7d32',
fontSize: '14px'
}}>
<div className={styles.modalSection}>
<div className={styles.modalSectionTitle}>
🧮 Calculation
</div>
<div style={{
color: '#1b5e20',
fontSize: '14px',
fontFamily: 'monospace',
backgroundColor: '#f1f8e9',
padding: '8px',
borderRadius: '4px',
border: '1px solid #c8e6c9'
}}>
<div className={styles.modalSectionContent}>
{calculation}
</div>
</div>
)}
{links && links.length > 0 && (
<div style={{
padding: '15px',
backgroundColor: '#f3e5f5',
borderRadius: '6px',
borderLeft: '4px solid #ba68c8'
}}>
<div style={{
fontWeight: 'bold',
marginBottom: '12px',
color: '#6a1b9a',
fontSize: '14px'
}}>
<div className={styles.modalSection}>
<div className={styles.modalSectionTitle}>
📚 Learn More
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div className={styles.modalLinks}>
{links.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
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'
}}
className={styles.modalLink}
>
🔗 {link.text}
</a>

View File

@ -12,6 +12,8 @@ export const TooltipType = {
CONNECTION_TIME: 'CONNECTION_TIME',
SERVER_LATENCY: 'SERVER_LATENCY',
REQUEST_URL: 'REQUEST_URL',
CONNECTION_NUMBER: 'CONNECTION_NUMBER',
REQUEST_NUMBER: 'REQUEST_NUMBER',
REQUEST_DURATION: 'REQUEST_DURATION',
DATA_RATE: 'DATA_RATE',
TOTAL_RESPONSE_TIME: 'TOTAL_RESPONSE_TIME',
@ -85,7 +87,7 @@ export const TOOLTIP_DEFINITIONS: Record<TooltipTypeValues, TooltipDefinition> =
[TooltipType.REQUEST_PRIORITY]: {
title: "Request Priority",
description: "Browser's internal priority for this request: VeryHigh, High, Medium, Low, VeryLow. Determines resource loading order.",
description: "Browser's internal priority for this request. Icons: 🔥 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.",
links: [
{ text: "Resource Prioritization", url: "https://web.dev/articles/resource-prioritization" },
@ -155,6 +157,27 @@ 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]: {
title: "Request Duration",
description: "Client-side time including queuing, network setup, and download. This is Total Response Time minus Server Latency.",

View File

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