Compare commits
18 Commits
2ee9c3fc28
...
b5b7aafca5
Author | SHA1 | Date | |
---|---|---|---|
b5b7aafca5 | |||
1035b28472 | |||
52543a5d04 | |||
a2e161bf2a | |||
c04735b1b9 | |||
d50ceb2a37 | |||
b3dedb3dbf | |||
33fb2b1674 | |||
327ef29d55 | |||
a6a7bbb65b | |||
488d9a2650 | |||
8075e54397 | |||
f9abfbc8ff | |||
33cafe695c | |||
fdccd59e04 | |||
357733fd15 | |||
9f41ff72f0 | |||
35537b8a5b |
@ -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
244498
examples/chek-plp.json
Normal file
File diff suppressed because one or more lines are too long
@ -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);
|
||||
}
|
||||
|
48
src/App.tsx
48
src/App.tsx
@ -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} />
|
||||
)}
|
||||
|
123
src/components/RequestBreakdown/BreakdownTableHeader.tsx
Normal file
123
src/components/RequestBreakdown/BreakdownTableHeader.tsx
Normal 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
|
219
src/components/RequestBreakdown/HostnameBreakdown.tsx
Normal file
219
src/components/RequestBreakdown/HostnameBreakdown.tsx
Normal 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
|
334
src/components/RequestBreakdown/RequestBreakdown.module.css
Normal file
334
src/components/RequestBreakdown/RequestBreakdown.module.css
Normal 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;
|
||||
}
|
||||
}
|
57
src/components/RequestBreakdown/RequestDataSummary.tsx
Normal file
57
src/components/RequestBreakdown/RequestDataSummary.tsx
Normal 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
|
192
src/components/RequestBreakdown/ResourceTypeBreakdown.tsx
Normal file
192
src/components/RequestBreakdown/ResourceTypeBreakdown.tsx
Normal 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
|
192
src/components/RequestBreakdown/StatusCodeBreakdown.tsx
Normal file
192
src/components/RequestBreakdown/StatusCodeBreakdown.tsx
Normal 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
|
120
src/components/RequestBreakdown/index.tsx
Normal file
120
src/components/RequestBreakdown/index.tsx
Normal 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
|
125
src/components/httprequestviewer/ColumnSettings.module.css
Normal file
125
src/components/httprequestviewer/ColumnSettings.module.css
Normal 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;
|
||||
}
|
122
src/components/httprequestviewer/ColumnSettings.tsx
Normal file
122
src/components/httprequestviewer/ColumnSettings.tsx
Normal 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
|
@ -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;
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
211
src/components/httprequestviewer/InitiatorChain.module.css
Normal file
211
src/components/httprequestviewer/InitiatorChain.module.css
Normal 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);
|
||||
}
|
||||
}
|
189
src/components/httprequestviewer/InitiatorChain.tsx
Normal file
189
src/components/httprequestviewer/InitiatorChain.tsx
Normal 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
|
256
src/components/httprequestviewer/InitiatorView.module.css
Normal file
256
src/components/httprequestviewer/InitiatorView.module.css
Normal 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;
|
||||
}
|
||||
}
|
158
src/components/httprequestviewer/InitiatorView.tsx
Normal file
158
src/components/httprequestviewer/InitiatorView.tsx
Normal 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
|
@ -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;
|
||||
}
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
73
src/components/httprequestviewer/RequestsTable.module.css
Normal file
73
src/components/httprequestviewer/RequestsTable.module.css
Normal 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);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
67
src/components/httprequestviewer/lib/connectionUtils.ts
Normal file
67
src/components/httprequestviewer/lib/connectionUtils.ts
Normal 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
|
||||
}
|
206
src/components/httprequestviewer/lib/csvExport.ts
Normal file
206
src/components/httprequestviewer/lib/csvExport.ts
Normal 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()
|
||||
}
|
@ -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(
|
||||
|
@ -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
|
||||
}
|
184
src/components/javascriptviewer/JavaScriptEventsTable.module.css
Normal file
184
src/components/javascriptviewer/JavaScriptEventsTable.module.css
Normal 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);
|
||||
}
|
147
src/components/javascriptviewer/JavaScriptEventsTable.tsx
Normal file
147
src/components/javascriptviewer/JavaScriptEventsTable.tsx
Normal 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
|
182
src/components/javascriptviewer/JavaScriptSummary.module.css
Normal file
182
src/components/javascriptviewer/JavaScriptSummary.module.css
Normal 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);
|
||||
}
|
111
src/components/javascriptviewer/JavaScriptSummary.tsx
Normal file
111
src/components/javascriptviewer/JavaScriptSummary.tsx
Normal 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
|
155
src/components/javascriptviewer/JavaScriptViewer.module.css
Normal file
155
src/components/javascriptviewer/JavaScriptViewer.module.css
Normal 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);
|
||||
}
|
||||
}
|
160
src/components/javascriptviewer/JavaScriptViewer.tsx
Normal file
160
src/components/javascriptviewer/JavaScriptViewer.tsx
Normal 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
|
64
src/components/javascriptviewer/lib/colorUtils.ts
Normal file
64
src/components/javascriptviewer/lib/colorUtils.ts
Normal 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 ''
|
||||
}
|
97
src/components/javascriptviewer/lib/formatUtils.ts
Normal file
97
src/components/javascriptviewer/lib/formatUtils.ts
Normal 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 '-'
|
||||
}
|
156
src/components/javascriptviewer/lib/javascriptProcessor.ts
Normal file
156
src/components/javascriptviewer/lib/javascriptProcessor.ts
Normal 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
|
||||
}
|
||||
}
|
54
src/components/javascriptviewer/types/javascriptEvent.ts
Normal file
54
src/components/javascriptviewer/types/javascriptEvent.ts
Normal 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
|
||||
}>
|
||||
}
|
71
src/components/shared/Modal.module.css
Normal file
71
src/components/shared/Modal.module.css
Normal 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;
|
||||
}
|
@ -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>
|
||||
|
174
src/components/shared/Tooltip.module.css
Normal file
174
src/components/shared/Tooltip.module.css
Normal 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;
|
||||
}
|
@ -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>
|
||||
|
@ -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.",
|
||||
|
@ -1,3 +1,4 @@
|
||||
|
||||
/**
|
||||
* Chrome DevTools Performance Trace Format
|
||||
* Based on the Trace Event Format specification
|
||||
|
Loading…
Reference in New Issue
Block a user