perfViz/src/components/HTTPRequestViewer.tsx
Michael Mainguy 8a791a1186 Implement comprehensive 3D timeline visualization with enhanced features
- Create new BabylonTimelineViewer with swimlane-based layout
- Add dual-box system: gray server time (50% opacity) + blue network time boxes
- Implement yellow queue time visualization with 25% opacity
- Add host-based swimlanes with alternating left/right positioning sorted by earliest request time
- Create timeline grid lines with adaptive time labels (microseconds/milliseconds/seconds)
- Add UniversalCamera with WASD keyboard navigation from behind timeline (z: -10)
- Implement vertical gradient coloring for stacked overlapping requests
- Extract reusable timeline label creation function
- Position hostname labels below ground level (y: -1) for cleaner visualization
- Support both 3D Network View (radial) and 3D Timeline View (swimlanes) as modal overlays
- Add SSIM.js integration for intelligent screenshot similarity analysis
- Enhance CDN detection with comprehensive Akamai patterns and improved accuracy
- Add server latency calculation and color-coded display
- Add content-length header extraction and color-coded display
- Move 3D viewer from main nav to HTTP requests page with modal interface

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 09:21:45 -05:00

2004 lines
83 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo, useEffect } from 'react'
import { useDatabaseTraceData } from '../hooks/useDatabaseTraceData'
import type { TraceEvent } from '../../types/trace'
import ssim from 'ssim.js'
import BabylonViewer from '../BabylonViewer'
import BabylonTimelineViewer from '../BabylonTimelineViewer'
interface QueueAnalysis {
reason: 'connection_limit' | 'priority_queue' | 'resource_contention' | 'unknown'
description: string
concurrentRequests: number
connectionId?: number
relatedRequests?: string[]
}
interface CDNAnalysis {
provider: 'cloudflare' | 'akamai' | 'aws_cloudfront' | 'fastly' | 'azure_cdn' | 'google_cdn' | 'cdn77' | 'keycdn' | 'unknown'
isEdge: boolean
cacheStatus?: 'hit' | 'miss' | 'expired' | 'bypass' | 'unknown'
edgeLocation?: string
confidence: number // 0-1 confidence score
detectionMethod: string // How we detected it
}
interface ScreenshotEvent {
timestamp: number
screenshot: string // base64 image data
index: number
}
interface HTTPRequest {
requestId: string
url: string
hostname: string
method: string
resourceType: string
priority: string
statusCode?: number
mimeType?: string
protocol?: string
events: {
willSendRequest?: TraceEvent
sendRequest?: TraceEvent
receiveResponse?: TraceEvent
receivedData?: TraceEvent[]
finishLoading?: TraceEvent
}
timing: {
start: number
startOffset?: number
end?: number
duration?: number
queueTime?: number
serverLatency?: number
networkDuration?: number
dnsStart?: number
dnsEnd?: number
connectStart?: number
connectEnd?: number
sslStart?: number
sslEnd?: number
sendStart?: number
sendEnd?: number
receiveHeadersStart?: number
receiveHeadersEnd?: number
}
responseHeaders?: Array<{ name: string, value: string }>
encodedDataLength?: number
contentLength?: number
fromCache: boolean
connectionReused: boolean
queueAnalysis?: QueueAnalysis
cdnAnalysis?: CDNAnalysis
}
const ITEMS_PER_PAGE = 25
// SSIM threshold for screenshot similarity (0-1, where 1 is identical)
// Values above this threshold are considered "similar enough" to filter out
const SSIM_SIMILARITY_THRESHOLD = 0.95
// Global highlighting constants
const HIGHLIGHTING_CONFIG = {
// File size thresholds (in KB)
SIZE_THRESHOLDS: {
LARGE: 500, // Red highlighting for files >= 500KB
MEDIUM: 100, // Yellow highlighting for files >= 100KB
SMALL: 50 // Green highlighting for files <= 50KB
},
// Duration thresholds (in milliseconds)
DURATION_THRESHOLDS: {
SLOW: 250, // Red highlighting for requests > 150ms
MEDIUM: 100, // Yellow highlighting for requests 50-150ms
FAST: 100 // Green highlighting for requests < 50ms
},
// Server latency thresholds (in microseconds)
SERVER_LATENCY_THRESHOLDS: {
SLOW: 200000, // Red highlighting for server latency > 200ms (200000 microseconds)
MEDIUM: 50000, // Yellow highlighting for server latency 50-200ms (50000 microseconds)
FAST: 50000 // Green highlighting for server latency < 50ms (50000 microseconds)
},
// Queue time thresholds (in microseconds)
QUEUE_TIME: {
HIGH_THRESHOLD: 10000, // 10ms - highlight in red and show analysis
ANALYSIS_THRESHOLD: 10000 // 10ms - minimum queue time for analysis
},
// Colors
COLORS: {
// Status colors
STATUS: {
SUCCESS: '#28a745', // 2xx
REDIRECT: '#ffc107', // 3xx
CLIENT_ERROR: '#fd7e14', // 4xx
SERVER_ERROR: '#dc3545', // 5xx
UNKNOWN: '#6c757d'
},
// Protocol colors
PROTOCOL: {
HTTP1_1: '#dc3545', // Red
H2: '#b8860b', // Dark yellow
H3: '#006400', // Dark green
UNKNOWN: '#6c757d'
},
// Priority colors
PRIORITY: {
VERY_HIGH: '#dc3545', // Red
HIGH: '#fd7e14', // Orange
MEDIUM: '#ffc107', // Yellow
LOW: '#28a745', // Green
VERY_LOW: '#6c757d', // Gray
UNKNOWN: '#6c757d'
},
// File size colors
SIZE: {
LARGE: { background: '#dc3545', color: 'white' }, // Red for 500KB+
MEDIUM: { background: '#ffc107', color: 'black' }, // Yellow for 100-500KB
SMALL: { background: '#28a745', color: 'white' }, // Green for under 50KB
DEFAULT: { background: 'transparent', color: '#495057' } // Default for 50-100KB
},
// Duration colors
DURATION: {
SLOW: { background: '#dc3545', color: 'white' }, // Red for > 150ms
MEDIUM: { background: '#ffc107', color: 'black' }, // Yellow for 50-150ms
FAST: { background: '#28a745', color: 'white' }, // Green for < 50ms
DEFAULT: { background: 'transparent', color: '#495057' } // Default
},
// Server latency colors
SERVER_LATENCY: {
SLOW: { background: '#dc3545', color: 'white' }, // Red for > 200ms
MEDIUM: { background: '#ffc107', color: 'black' }, // Yellow for 50-200ms
FAST: { background: '#28a745', color: 'white' }, // Green for < 50ms
DEFAULT: { background: 'transparent', color: '#495057' } // Default
}
}
}
const getHostnameFromUrl = (url: string): string => {
try {
const urlObj = new URL(url)
return urlObj.hostname
} catch {
return 'unknown'
}
}
// Helper function to convert base64 image to ImageData for SSIM analysis
const base64ToImageData = (base64: string): Promise<ImageData> => {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Could not get canvas context'))
return
}
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
resolve(imageData)
}
img.onerror = () => reject(new Error('Failed to load image'))
img.src = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`
})
}
const extractScreenshots = (traceEvents: TraceEvent[]): ScreenshotEvent[] => {
const screenshots: ScreenshotEvent[] = []
let index = 0
// Debug: Check for any screenshot-related events
const screenshotRelated = traceEvents.filter(event =>
event.name.toLowerCase().includes('screenshot') ||
event.cat.toLowerCase().includes('screenshot') ||
event.name.toLowerCase().includes('snap') ||
event.name.toLowerCase().includes('frame') ||
event.cat.toLowerCase().includes('devtools')
)
console.log('Debug: Screenshot-related events found:', screenshotRelated.length)
if (screenshotRelated.length > 0) {
console.log('Debug: Screenshot-related sample:', screenshotRelated.slice(0, 5).map(e => ({
name: e.name,
cat: e.cat,
hasArgs: !!e.args,
argsKeys: e.args ? Object.keys(e.args) : []
})))
}
for (const event of traceEvents) {
// Look for screenshot events with more flexible matching
if ((event.name === 'Screenshot' || event.name === 'screenshot') &&
(event.cat === 'disabled-by-default-devtools.screenshot' ||
event.cat.includes('screenshot') ||
event.cat.includes('devtools')) &&
event.args) {
const args = event.args as any
// Check multiple possible locations for screenshot data
const snapshot = args.snapshot || args.data?.snapshot || args.image || args.data?.image
if (snapshot && typeof snapshot === 'string') {
// Accept both data URLs and base64 strings
if (snapshot.startsWith('data:image/') || snapshot.startsWith('/9j/') || snapshot.length > 1000) {
const screenshotData = snapshot.startsWith('data:image/') ? snapshot : `data:image/jpeg;base64,${snapshot}`
screenshots.push({
timestamp: event.ts,
screenshot: screenshotData,
index: index++
})
}
}
}
}
console.log('Debug: Extracted screenshots:', screenshots.length)
return screenshots.sort((a, b) => a.timestamp - b.timestamp)
}
const findUniqueScreenshots = async (screenshots: ScreenshotEvent[], threshold: number = SSIM_SIMILARITY_THRESHOLD): Promise<ScreenshotEvent[]> => {
if (screenshots.length === 0) return []
const uniqueScreenshots: ScreenshotEvent[] = [screenshots[0]] // Always include first screenshot
console.log('SSIM Analysis: Processing', screenshots.length, 'screenshots')
// Compare each screenshot with the previous unique one using SSIM
for (let i = 1; i < screenshots.length; i++) {
const current = screenshots[i]
const lastUnique = uniqueScreenshots[uniqueScreenshots.length - 1]
try {
// Convert both images to ImageData for SSIM analysis
const [currentImageData, lastImageData] = await Promise.all([
base64ToImageData(current.screenshot),
base64ToImageData(lastUnique.screenshot)
])
// Calculate SSIM similarity (0-1, where 1 is identical)
const similarity = ssim(currentImageData, lastImageData)
console.log(`SSIM Analysis: Screenshot ${i} similarity: ${similarity.toFixed(4)}`)
// If similarity is below threshold, it's different enough to keep
if (similarity < threshold) {
uniqueScreenshots.push(current)
console.log(`SSIM Analysis: Screenshot ${i} added (significant change detected)`)
} else {
console.log(`SSIM Analysis: Screenshot ${i} filtered out (too similar: ${similarity.toFixed(4)})`)
}
} catch (error) {
console.warn('SSIM Analysis: Error comparing screenshots, falling back to string comparison:', error)
// Fallback to simple string comparison if SSIM fails
if (current.screenshot !== lastUnique.screenshot) {
uniqueScreenshots.push(current)
}
}
}
console.log('SSIM Analysis: Filtered from', screenshots.length, 'to', uniqueScreenshots.length, 'unique screenshots')
return uniqueScreenshots
}
const analyzeCDN = (request: HTTPRequest): CDNAnalysis => {
const hostname = request.hostname
const headers = request.responseHeaders || []
const headerMap = new Map(headers.map(h => [h.name.toLowerCase(), h.value.toLowerCase()]))
let provider: CDNAnalysis['provider'] = 'unknown'
let isEdge = false
let cacheStatus: CDNAnalysis['cacheStatus'] = 'unknown'
let edgeLocation: string | undefined
let confidence = 0
let detectionMethod = 'unknown'
// Enhanced Akamai detection (check this first as it's often missed)
if (headerMap.has('akamai-cache-status') ||
headerMap.has('x-cache-key') ||
headerMap.has('x-akamai-request-id') ||
headerMap.has('x-akamai-edgescape') ||
headerMap.get('server')?.includes('akamai') ||
headerMap.get('server')?.includes('akamainet') ||
headerMap.get('server')?.includes('akamainetworking') ||
headerMap.get('x-cache')?.includes('akamai') ||
hostname.includes('akamai') ||
hostname.includes('akamaized') ||
hostname.includes('akamaicdn') ||
hostname.includes('akamai-staging') ||
// Check for Akamai Edge hostnames patterns
/e\d+\.a\.akamaiedge\.net/.test(hostname) ||
// Check Via header for Akamai
headerMap.get('via')?.includes('akamai') ||
// Check for Akamai Image Manager server header
(headerMap.get('server') === 'akamai image manager') ||
// Check for typical Akamai headers
headerMap.has('x-serial') ||
// Additional Akamai detection patterns
headerMap.has('x-akamai-session-info') ||
headerMap.has('x-akamai-ssl-client-sid') ||
headerMap.get('x-cache')?.includes('tcp_hit') ||
headerMap.get('x-cache')?.includes('tcp_miss') ||
// Akamai sometimes uses x-served-by with specific patterns
(headerMap.has('x-served-by') && headerMap.get('x-served-by')?.includes('cache-'))) {
provider = 'akamai'
confidence = 0.95
detectionMethod = 'enhanced akamai detection (headers/hostname/server)'
isEdge = true
const akamaiStatus = headerMap.get('akamai-cache-status')
if (akamaiStatus) {
if (akamaiStatus.includes('hit')) cacheStatus = 'hit'
else if (akamaiStatus.includes('miss')) cacheStatus = 'miss'
}
// Also check x-cache for Akamai cache status
const xCache = headerMap.get('x-cache')
if (xCache && !akamaiStatus) {
if (xCache.includes('hit')) cacheStatus = 'hit'
else if (xCache.includes('miss')) cacheStatus = 'miss'
else if (xCache.includes('tcp_hit')) cacheStatus = 'hit'
else if (xCache.includes('tcp_miss')) cacheStatus = 'miss'
}
}
// Cloudflare detection
else if (headerMap.has('cf-ray') || headerMap.has('cf-cache-status') || hostname.includes('cloudflare')) {
provider = 'cloudflare'
confidence = 0.95
detectionMethod = 'headers (cf-ray/cf-cache-status)'
isEdge = true
const cfCacheStatus = headerMap.get('cf-cache-status')
if (cfCacheStatus) {
if (cfCacheStatus.includes('hit')) cacheStatus = 'hit'
else if (cfCacheStatus.includes('miss')) cacheStatus = 'miss'
else if (cfCacheStatus.includes('expired')) cacheStatus = 'expired'
else if (cfCacheStatus.includes('bypass')) cacheStatus = 'bypass'
}
edgeLocation = headerMap.get('cf-ray')?.split('-')[1]
}
// AWS CloudFront detection
else if (headerMap.has('x-amz-cf-id') || headerMap.has('x-amz-cf-pop') ||
hostname.includes('cloudfront.net')) {
provider = 'aws_cloudfront'
confidence = 0.95
detectionMethod = 'headers (x-amz-cf-id/x-amz-cf-pop)'
isEdge = true
const cloudfrontCache = headerMap.get('x-cache')
if (cloudfrontCache) {
if (cloudfrontCache.includes('hit')) cacheStatus = 'hit'
else if (cloudfrontCache.includes('miss')) cacheStatus = 'miss'
}
edgeLocation = headerMap.get('x-amz-cf-pop')
}
// Fastly detection (moved after Akamai to avoid false positives)
else if (headerMap.has('fastly-debug-digest') ||
headerMap.has('fastly-debug-path') ||
hostname.includes('fastly.com') ||
hostname.includes('fastlylb.net') ||
headerMap.get('via')?.includes('fastly') ||
headerMap.get('x-cache')?.includes('fastly') ||
// Only use x-served-by for Fastly if it has specific Fastly patterns
(headerMap.has('x-served-by') && (
headerMap.get('x-served-by')?.includes('fastly') ||
headerMap.get('x-served-by')?.includes('f1') ||
headerMap.get('x-served-by')?.includes('f2')
))) {
provider = 'fastly'
confidence = 0.85
detectionMethod = 'headers (fastly-debug-digest/via/x-cache)'
isEdge = true
const fastlyCache = headerMap.get('x-cache')
if (fastlyCache) {
if (fastlyCache.includes('hit')) cacheStatus = 'hit'
else if (fastlyCache.includes('miss')) cacheStatus = 'miss'
}
}
// Azure CDN detection
else if (headerMap.has('x-azure-ref') || hostname.includes('azureedge.net')) {
provider = 'azure_cdn'
confidence = 0.9
detectionMethod = 'headers (x-azure-ref) or hostname'
isEdge = true
}
// Google CDN detection
else if (headerMap.has('x-goog-generation') || hostname.includes('googleapis.com') ||
headerMap.get('server')?.includes('gws')) {
provider = 'google_cdn'
confidence = 0.8
detectionMethod = 'headers (x-goog-generation) or server'
isEdge = true
}
// Generic CDN patterns
else if (hostname.includes('cdn') || hostname.includes('cache') ||
hostname.includes('edge') || hostname.includes('static')) {
provider = 'unknown'
confidence = 0.3
detectionMethod = 'hostname patterns (cdn/cache/edge/static)'
isEdge = true
}
// Check for origin indicators (lower confidence for edge)
if (hostname.includes('origin') || hostname.includes('api') ||
headerMap.get('x-cache')?.includes('miss from origin')) {
isEdge = false
confidence = Math.max(0.1, confidence - 0.2)
detectionMethod += ' + origin indicators'
}
return {
provider,
isEdge,
cacheStatus,
edgeLocation,
confidence,
detectionMethod
}
}
const analyzeQueueReason = (request: HTTPRequest, allRequests: HTTPRequest[]): QueueAnalysis => {
const queueTime = request.timing.queueTime || 0
// If no significant queue time, no analysis needed
if (queueTime < HIGHLIGHTING_CONFIG.QUEUE_TIME.ANALYSIS_THRESHOLD) {
return {
reason: 'unknown',
description: 'No significant queueing detected',
concurrentRequests: 0
}
}
const requestStart = request.timing.start
const requestEnd = request.timing.end || requestStart + (request.timing.duration || 0)
// Find concurrent requests to the same host
const concurrentToSameHost = allRequests.filter(other => {
if (other.requestId === request.requestId) return false
if (other.hostname !== request.hostname) return false
const otherStart = other.timing.start
const otherEnd = other.timing.end || otherStart + (other.timing.duration || 0)
// Check if requests overlap in time
return (otherStart <= requestEnd && otherEnd >= requestStart)
})
const concurrentSameProtocol = concurrentToSameHost.filter(r => r.protocol === request.protocol)
// HTTP/1.1 connection limit analysis (typically 6 connections per host)
if (request.protocol === 'http/1.1' && concurrentSameProtocol.length >= 6) {
return {
reason: 'connection_limit',
description: `HTTP/1.1 connection limit reached (${concurrentSameProtocol.length} concurrent requests)`,
concurrentRequests: concurrentSameProtocol.length,
relatedRequests: concurrentSameProtocol.slice(0, 5).map(r => r.requestId)
}
}
// H2 multiplexing but still queued - likely priority or server limits
if (request.protocol === 'h2' && concurrentSameProtocol.length > 0) {
// Check if lower priority requests are being processed first
const lowerPriorityActive = concurrentSameProtocol.filter(r => {
const priorityOrder = ['VeryLow', 'Low', 'Medium', 'High', 'VeryHigh']
const requestPriorityIndex = priorityOrder.indexOf(request.priority || 'Medium')
const otherPriorityIndex = priorityOrder.indexOf(r.priority || 'Medium')
return otherPriorityIndex < requestPriorityIndex
})
if (lowerPriorityActive.length > 0) {
return {
reason: 'priority_queue',
description: `Queued behind ${lowerPriorityActive.length} lower priority requests`,
concurrentRequests: concurrentSameProtocol.length,
relatedRequests: lowerPriorityActive.slice(0, 3).map(r => r.requestId)
}
}
return {
reason: 'resource_contention',
description: `Server resource contention (${concurrentSameProtocol.length} concurrent H2 requests)`,
concurrentRequests: concurrentSameProtocol.length,
relatedRequests: concurrentSameProtocol.slice(0, 3).map(r => r.requestId)
}
}
// General resource contention
if (concurrentToSameHost.length > 0) {
return {
reason: 'resource_contention',
description: `Resource contention with ${concurrentToSameHost.length} concurrent requests`,
concurrentRequests: concurrentToSameHost.length,
relatedRequests: concurrentToSameHost.slice(0, 3).map(r => r.requestId)
}
}
return {
reason: 'unknown',
description: `Queued for ${(queueTime / 1000).toFixed(1)}ms - reason unclear`,
concurrentRequests: 0
}
}
interface HTTPRequestViewerProps {
traceId: string | null
}
export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
const { traceData, loading, error } = useDatabaseTraceData(traceId)
const [currentPage, setCurrentPage] = useState(1)
const [searchTerm, setSearchTerm] = useState('')
const [resourceTypeFilter, setResourceTypeFilter] = useState<string>('all')
const [protocolFilter, setProtocolFilter] = useState<string>('all')
const [hostnameFilter, setHostnameFilter] = useState<string>('all')
const [priorityFilter, setPriorityFilter] = useState<string>('all')
const [showQueueAnalysis, setShowQueueAnalysis] = useState(false)
const [showScreenshots, setShowScreenshots] = useState(false)
const [show3DViewer, setShow3DViewer] = useState(false)
const [showTimelineViewer, setShowTimelineViewer] = useState(false)
const [ssimThreshold, setSsimThreshold] = useState(SSIM_SIMILARITY_THRESHOLD)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const httpRequests = useMemo(() => {
if (!traceData) return []
const requestsMap = new Map<string, HTTPRequest>()
// Process all events and group by requestId
for (const event of traceData.traceEvents) {
const args = event.args as any
const requestId = args?.data?.requestId
if (!requestId) continue
if (!requestsMap.has(requestId)) {
requestsMap.set(requestId, {
requestId,
url: '',
hostname: '',
method: '',
resourceType: '',
priority: '',
events: {},
timing: { start: event.ts },
fromCache: false,
connectionReused: false
})
}
const request = requestsMap.get(requestId)!
switch (event.name) {
case 'ResourceWillSendRequest':
request.events.willSendRequest = event
request.timing.start = Math.min(request.timing.start, event.ts)
break
case 'ResourceSendRequest':
request.events.sendRequest = event
request.url = args.data.url || ''
request.hostname = getHostnameFromUrl(request.url)
request.method = args.data.requestMethod || ''
request.resourceType = args.data.resourceType || ''
request.priority = args.data.priority || ''
request.timing.start = Math.min(request.timing.start, event.ts)
break
case 'ResourceReceiveResponse':
request.events.receiveResponse = event
request.statusCode = args.data.statusCode
request.mimeType = args.data.mimeType
request.protocol = args.data.protocol
request.responseHeaders = args.data.headers
// Extract content-length from response headers
if (request.responseHeaders) {
const contentLengthHeader = request.responseHeaders.find(
header => header.name.toLowerCase() === 'content-length'
)
if (contentLengthHeader) {
request.contentLength = parseInt(contentLengthHeader.value, 10)
}
}
request.fromCache = args.data.fromCache || false
request.connectionReused = args.data.connectionReused || false
request.timing.end = event.ts
// Extract network timing details
const timing = args.data.timing
if (timing) {
request.timing.networkDuration = timing.receiveHeadersEnd
request.timing.dnsStart = timing.dnsStart
request.timing.dnsEnd = timing.dnsEnd
request.timing.connectStart = timing.connectStart
request.timing.connectEnd = timing.connectEnd
request.timing.sslStart = timing.sslStart
request.timing.sslEnd = timing.sslEnd
request.timing.sendStart = timing.sendStart
request.timing.sendEnd = timing.sendEnd
request.timing.receiveHeadersStart = timing.receiveHeadersStart
request.timing.receiveHeadersEnd = timing.receiveHeadersEnd
}
break
case 'ResourceReceivedData':
if (!request.events.receivedData) request.events.receivedData = []
request.events.receivedData.push(event)
request.encodedDataLength = (request.encodedDataLength || 0) + (args.data.encodedDataLength || 0)
request.timing.end = Math.max(request.timing.end || event.ts, event.ts)
break
case 'ResourceFinishLoading':
request.events.finishLoading = event
request.timing.end = event.ts
break
}
// Calculate total duration
if (request.timing.end) {
request.timing.duration = request.timing.end - request.timing.start
}
}
// Calculate queue times and filter valid requests
const sortedRequests = Array.from(requestsMap.values())
.filter(req => req.url && req.method) // Only include valid requests
.map(request => {
// Calculate queue time using multiple approaches
if (request.events.willSendRequest && request.events.sendRequest) {
// Primary approach: ResourceSendRequest - ResourceWillSendRequest
request.timing.queueTime = request.events.sendRequest.ts - request.events.willSendRequest.ts
} else if (request.events.sendRequest && request.events.receiveResponse) {
// Fallback approach: use timing data from ResourceReceiveResponse
const responseArgs = request.events.receiveResponse.args as any
const timingData = responseArgs?.data?.timing
if (timingData?.requestTime) {
// requestTime is in seconds, convert to microseconds
const requestTimeUs = timingData.requestTime * 1000000
// Queue time = requestTime (when browser queued) - ResourceSendRequest timestamp (when DevTools recorded)
// Note: requestTime is the actual browser queue time, ResourceSendRequest is the DevTools event
request.timing.queueTime = requestTimeUs - request.events.sendRequest.ts
}
}
// Calculate server latency (server processing time = total duration - queue time)
if (request.timing.duration && request.timing.queueTime) {
request.timing.serverLatency = request.timing.duration - request.timing.queueTime
// Ensure server latency is not negative (can happen with timing irregularities)
if (request.timing.serverLatency < 0) {
request.timing.serverLatency = 0
}
}
return request
})
.sort((a, b) => a.timing.start - b.timing.start)
// Calculate start time offsets from the first request
if (sortedRequests.length > 0) {
const firstRequestTime = sortedRequests[0].timing.start
sortedRequests.forEach(request => {
request.timing.startOffset = request.timing.start - firstRequestTime
})
}
// Add queue analysis for requests with significant queue time
sortedRequests.forEach(request => {
if ((request.timing.queueTime || 0) >= HIGHLIGHTING_CONFIG.QUEUE_TIME.ANALYSIS_THRESHOLD) {
request.queueAnalysis = analyzeQueueReason(request, sortedRequests)
}
})
// Add CDN analysis for all requests
sortedRequests.forEach(request => {
if (request.responseHeaders && request.responseHeaders.length > 0) {
request.cdnAnalysis = analyzeCDN(request)
}
})
return sortedRequests
}, [traceData])
// Extract and process screenshots with SSIM analysis
const [screenshots, setScreenshots] = useState<ScreenshotEvent[]>([])
const [screenshotsLoading, setScreenshotsLoading] = useState(false)
useEffect(() => {
if (!traceData) {
setScreenshots([])
return
}
const processScreenshots = async () => {
setScreenshotsLoading(true)
try {
const allScreenshots = extractScreenshots(traceData.traceEvents)
console.log('Debug: Found screenshots:', allScreenshots.length)
console.log('Debug: Screenshot events sample:', allScreenshots.slice(0, 3))
const uniqueScreenshots = await findUniqueScreenshots(allScreenshots, ssimThreshold)
console.log('Debug: Unique screenshots after SSIM analysis:', uniqueScreenshots.length)
setScreenshots(uniqueScreenshots)
} catch (error) {
console.error('Error processing screenshots with SSIM:', error)
// Fallback to extracting all screenshots without SSIM filtering
const allScreenshots = extractScreenshots(traceData.traceEvents)
setScreenshots(allScreenshots)
} finally {
setScreenshotsLoading(false)
}
}
processScreenshots()
}, [traceData, ssimThreshold])
const filteredRequests = useMemo(() => {
let requests = httpRequests
// Filter by resource type
if (resourceTypeFilter !== 'all') {
requests = requests.filter(req => req.resourceType === resourceTypeFilter)
}
// Filter by protocol
if (protocolFilter !== 'all') {
requests = requests.filter(req => req.protocol === protocolFilter)
}
// Filter by hostname
if (hostnameFilter !== 'all') {
requests = requests.filter(req => req.hostname === hostnameFilter)
}
// Filter by priority
if (priorityFilter !== 'all') {
requests = requests.filter(req => req.priority === priorityFilter)
}
// Filter by search term
if (searchTerm) {
const term = searchTerm.toLowerCase()
requests = requests.filter(req =>
req.url.toLowerCase().includes(term) ||
req.method.toLowerCase().includes(term) ||
req.resourceType.toLowerCase().includes(term) ||
req.hostname.toLowerCase().includes(term) ||
req.statusCode?.toString().includes(term)
)
}
return requests
}, [httpRequests, resourceTypeFilter, protocolFilter, hostnameFilter, priorityFilter, searchTerm])
const paginatedRequests = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
return filteredRequests.slice(startIndex, startIndex + ITEMS_PER_PAGE)
}, [filteredRequests, currentPage])
// Create timeline entries that include both HTTP requests and screenshot changes
const timelineEntries = useMemo(() => {
const entries: Array<{
type: 'request' | 'screenshot',
timestamp: number,
data: HTTPRequest | ScreenshotEvent
}> = []
// Add HTTP requests
filteredRequests.forEach(request => {
entries.push({
type: 'request',
timestamp: request.timing.start,
data: request
})
})
// Add screenshots if enabled
if (showScreenshots && screenshots.length > 0) {
screenshots.forEach(screenshot => {
entries.push({
type: 'screenshot',
timestamp: screenshot.timestamp,
data: screenshot
})
})
}
// Sort by timestamp
return entries.sort((a, b) => a.timestamp - b.timestamp)
}, [filteredRequests, screenshots, showScreenshots])
const paginatedTimelineEntries = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
return timelineEntries.slice(startIndex, startIndex + ITEMS_PER_PAGE)
}, [timelineEntries, currentPage])
const resourceTypes = useMemo(() => {
const types = new Set(httpRequests.map(req => req.resourceType))
return Array.from(types).sort()
}, [httpRequests])
const protocols = useMemo(() => {
const protos = new Set(httpRequests.map(req => req.protocol).filter(Boolean))
return Array.from(protos).sort()
}, [httpRequests])
const hostnames = useMemo(() => {
const hosts = new Set(httpRequests.map(req => req.hostname).filter(h => h !== 'unknown'))
return Array.from(hosts).sort()
}, [httpRequests])
const priorities = useMemo(() => {
const prios = new Set(httpRequests.map(req => req.priority).filter(Boolean))
return Array.from(prios).sort((a, b) => {
// Sort priorities by importance: VeryHigh, High, Medium, Low, VeryLow
const order = ['VeryHigh', 'High', 'Medium', 'Low', 'VeryLow']
return order.indexOf(a) - order.indexOf(b)
})
}, [httpRequests])
const totalPages = Math.ceil((showScreenshots ? timelineEntries.length : filteredRequests.length) / ITEMS_PER_PAGE)
const formatDuration = (microseconds?: number) => {
if (!microseconds) return '-'
if (microseconds < 1000) return `${microseconds.toFixed(0)}μs`
return `${(microseconds / 1000).toFixed(2)}ms`
}
const formatSize = (bytes?: number) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
const getStatusColor = (status?: number) => {
if (!status) return HIGHLIGHTING_CONFIG.COLORS.STATUS.UNKNOWN
if (status < 300) return HIGHLIGHTING_CONFIG.COLORS.STATUS.SUCCESS
if (status < 400) return HIGHLIGHTING_CONFIG.COLORS.STATUS.REDIRECT
if (status < 500) return HIGHLIGHTING_CONFIG.COLORS.STATUS.CLIENT_ERROR
return HIGHLIGHTING_CONFIG.COLORS.STATUS.SERVER_ERROR
}
const getProtocolColor = (protocol?: string) => {
if (!protocol) return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.UNKNOWN
if (protocol === 'http/1.1') return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.HTTP1_1
if (protocol === 'h2') return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.H2
if (protocol === 'h3') return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.H3
return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.UNKNOWN
}
const getPriorityColor = (priority?: string) => {
if (!priority) return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.UNKNOWN
if (priority === 'VeryHigh') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.VERY_HIGH
if (priority === 'High') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.HIGH
if (priority === 'Medium') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.MEDIUM
if (priority === 'Low') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.LOW
if (priority === 'VeryLow') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.VERY_LOW
return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.UNKNOWN
}
const getQueueAnalysisIcon = (analysis?: QueueAnalysis) => {
if (!analysis) return ''
switch (analysis.reason) {
case 'connection_limit': return '🚫'
case 'priority_queue': return '⏳'
case 'resource_contention': return '⚠️'
default: return '❓'
}
}
const getSizeColor = (bytes?: number) => {
if (!bytes) return HIGHLIGHTING_CONFIG.COLORS.SIZE.DEFAULT
const sizeKB = bytes / 1024
if (sizeKB >= HIGHLIGHTING_CONFIG.SIZE_THRESHOLDS.LARGE) {
return HIGHLIGHTING_CONFIG.COLORS.SIZE.LARGE
} else if (sizeKB >= HIGHLIGHTING_CONFIG.SIZE_THRESHOLDS.MEDIUM) {
return HIGHLIGHTING_CONFIG.COLORS.SIZE.MEDIUM
} else if (sizeKB <= HIGHLIGHTING_CONFIG.SIZE_THRESHOLDS.SMALL) {
return HIGHLIGHTING_CONFIG.COLORS.SIZE.SMALL
}
return HIGHLIGHTING_CONFIG.COLORS.SIZE.DEFAULT
}
const getDurationColor = (microseconds?: number) => {
if (!microseconds) return HIGHLIGHTING_CONFIG.COLORS.DURATION.DEFAULT
const durationMs = microseconds / 1000
if (durationMs > HIGHLIGHTING_CONFIG.DURATION_THRESHOLDS.SLOW) {
return HIGHLIGHTING_CONFIG.COLORS.DURATION.SLOW
} else if (durationMs >= HIGHLIGHTING_CONFIG.DURATION_THRESHOLDS.MEDIUM) {
return HIGHLIGHTING_CONFIG.COLORS.DURATION.MEDIUM
} else if (durationMs < HIGHLIGHTING_CONFIG.DURATION_THRESHOLDS.FAST) {
return HIGHLIGHTING_CONFIG.COLORS.DURATION.FAST
}
return HIGHLIGHTING_CONFIG.COLORS.DURATION.DEFAULT
}
const getServerLatencyColor = (microseconds?: number) => {
if (!microseconds) return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.DEFAULT
if (microseconds > HIGHLIGHTING_CONFIG.SERVER_LATENCY_THRESHOLDS.SLOW) {
return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.SLOW
} else if (microseconds >= HIGHLIGHTING_CONFIG.SERVER_LATENCY_THRESHOLDS.MEDIUM) {
return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.MEDIUM
} else if (microseconds < HIGHLIGHTING_CONFIG.SERVER_LATENCY_THRESHOLDS.FAST) {
return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.FAST
}
return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.DEFAULT
}
const getCDNIcon = (analysis?: CDNAnalysis) => {
if (!analysis) return ''
const providerIcons = {
cloudflare: '🟠',
akamai: '🔵',
aws_cloudfront: '🟡',
fastly: '⚡',
azure_cdn: '🟦',
google_cdn: '🟢',
cdn77: '🟣',
keycdn: '🔶',
unknown: '📡'
}
const edgeIcon = analysis.isEdge ? '🌐' : '🏠'
const cacheIcon = analysis.cacheStatus === 'hit' ? '✅' :
analysis.cacheStatus === 'miss' ? '❌' :
analysis.cacheStatus === 'expired' ? '⏰' : ''
return `${providerIcons[analysis.provider]}${edgeIcon}${cacheIcon}`
}
const getCDNDisplayName = (provider: CDNAnalysis['provider']) => {
const names = {
cloudflare: 'Cloudflare',
akamai: 'Akamai',
aws_cloudfront: 'CloudFront',
fastly: 'Fastly',
azure_cdn: 'Azure CDN',
google_cdn: 'Google CDN',
cdn77: 'CDN77',
keycdn: 'KeyCDN',
unknown: 'Unknown CDN'
}
return names[provider]
}
const toggleRowExpansion = (requestId: string) => {
const newExpanded = new Set(expandedRows)
if (newExpanded.has(requestId)) {
newExpanded.delete(requestId)
} else {
newExpanded.add(requestId)
}
setExpandedRows(newExpanded)
}
const truncateUrl = (url: string, maxLength: number = 60) => {
if (url.length <= maxLength) return url
return url.substring(0, maxLength) + '...'
}
if (loading) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: '400px',
fontSize: '18px',
color: '#6c757d'
}}>
<div style={{
width: '50px',
height: '50px',
border: '4px solid #f3f3f3',
borderTop: '4px solid #007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: '20px'
}} />
<div>Loading HTTP requests...</div>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
)
}
if (error) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<div style={{
background: '#f8d7da',
color: '#721c24',
padding: '15px',
borderRadius: '8px',
border: '1px solid #f5c6cb'
}}>
<h3>Error Loading Trace Data</h3>
<p>{error}</p>
</div>
</div>
)
}
return (
<div style={{ padding: '20px', fontFamily: 'system-ui, sans-serif' }}>
<h2>HTTP Requests & Responses</h2>
{/* Debug info - remove after investigation */}
{traceData && (
<div style={{
background: '#f8f9fa',
padding: '10px',
borderRadius: '4px',
marginBottom: '10px',
fontSize: '12px',
color: '#6c757d'
}}>
<strong>Debug:</strong> Total events: {traceData.traceEvents.length.toLocaleString()},
Screenshots found: {screenshots.length},
HTTP requests: {httpRequests.length}
<br />
<strong>Categories sample:</strong> {Array.from(new Set(traceData.traceEvents.slice(0, 1000).map(e => e.cat))).slice(0, 10).join(', ')}
<br />
<strong>Event names sample:</strong> {Array.from(new Set(traceData.traceEvents.slice(0, 1000).map(e => e.name))).slice(0, 10).join(', ')}
</div>
)}
{/* Controls */}
<div style={{
background: '#f8f9fa',
padding: '20px',
borderRadius: '8px',
marginBottom: '20px',
display: 'flex',
flexWrap: 'wrap',
gap: '15px',
alignItems: 'center'
}}>
{/* Resource Type Filter */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Resource Type:</label>
<select
value={resourceTypeFilter}
onChange={(e) => {
setResourceTypeFilter(e.target.value)
setCurrentPage(1)
}}
style={{
padding: '8px 12px',
border: '1px solid #ced4da',
borderRadius: '4px',
fontSize: '14px',
minWidth: '150px'
}}
>
<option value="all">All Types ({httpRequests.length})</option>
{resourceTypes.map(type => (
<option key={type} value={type}>
{type} ({httpRequests.filter(r => r.resourceType === type).length})
</option>
))}
</select>
</div>
{/* Protocol Filter */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Protocol:</label>
<select
value={protocolFilter}
onChange={(e) => {
setProtocolFilter(e.target.value)
setCurrentPage(1)
}}
style={{
padding: '8px 12px',
border: '1px solid #ced4da',
borderRadius: '4px',
fontSize: '14px',
minWidth: '120px'
}}
>
<option value="all">All Protocols</option>
{protocols.map(protocol => (
<option key={protocol} value={protocol}>
{protocol} ({httpRequests.filter(r => r.protocol === protocol).length})
</option>
))}
</select>
</div>
{/* Hostname Filter */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Hostname:</label>
<select
value={hostnameFilter}
onChange={(e) => {
setHostnameFilter(e.target.value)
setCurrentPage(1)
}}
style={{
padding: '8px 12px',
border: '1px solid #ced4da',
borderRadius: '4px',
fontSize: '14px',
minWidth: '200px'
}}
>
<option value="all">All Hostnames</option>
{hostnames.map(hostname => (
<option key={hostname} value={hostname}>
{hostname} ({httpRequests.filter(r => r.hostname === hostname).length})
</option>
))}
</select>
</div>
{/* Priority Filter */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Priority:</label>
<select
value={priorityFilter}
onChange={(e) => {
setPriorityFilter(e.target.value)
setCurrentPage(1)
}}
style={{
padding: '8px 12px',
border: '1px solid #ced4da',
borderRadius: '4px',
fontSize: '14px',
minWidth: '130px'
}}
>
<option value="all">All Priorities</option>
{priorities.map(priority => (
<option key={priority} value={priority}>
{priority} ({httpRequests.filter(r => r.priority === priority).length})
</option>
))}
</select>
</div>
{/* Search */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Search:</label>
<input
type="text"
placeholder="Search URL, method, type, or status..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setCurrentPage(1)
}}
style={{
padding: '8px 12px',
border: '1px solid #ced4da',
borderRadius: '4px',
fontSize: '14px',
minWidth: '300px'
}}
/>
</div>
{/* Queue Analysis Toggle */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Queue Analysis:</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '14px' }}>
<input
type="checkbox"
checked={showQueueAnalysis}
onChange={(e) => setShowQueueAnalysis(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
Show queue analysis
</label>
</div>
{/* 3D Viewer Toggle */}
{httpRequests.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>3D Visualization:</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '14px' }}>
<input
type="checkbox"
checked={show3DViewer}
onChange={(e) => setShow3DViewer(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
Show 3D Network View
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '14px' }}>
<input
type="checkbox"
checked={showTimelineViewer}
onChange={(e) => setShowTimelineViewer(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
Show 3D Timeline View
</label>
</div>
)}
{/* Screenshots Toggle */}
{(screenshots.length > 0 || screenshotsLoading) && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Filmstrip:</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '14px' }}>
<input
type="checkbox"
checked={showScreenshots}
onChange={(e) => setShowScreenshots(e.target.checked)}
style={{ cursor: 'pointer' }}
disabled={screenshotsLoading}
/>
{screenshotsLoading ? (
<span style={{ color: '#007bff' }}>
🔍 Analyzing screenshots with SSIM...
</span>
) : (
`Show screenshots (${screenshots.length} unique frames)`
)}
</label>
{screenshots.length > 0 && !screenshotsLoading && (
<div style={{ marginTop: '5px', fontSize: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>SSIM Similarity Threshold:</span>
<input
type="range"
min="0.8"
max="0.99"
step="0.01"
value={ssimThreshold}
onChange={(e) => setSsimThreshold(parseFloat(e.target.value))}
style={{ width: '100px' }}
/>
<span>{ssimThreshold.toFixed(2)}</span>
</label>
<div style={{ fontSize: '10px', color: '#6c757d', marginTop: '2px' }}>
Lower values = more screenshots kept (more sensitive to changes)
</div>
</div>
)}
</div>
)}
{/* Results count */}
<div style={{
marginLeft: 'auto',
fontSize: '14px',
color: '#6c757d',
fontWeight: 'bold'
}}>
{showScreenshots ?
`${timelineEntries.length.toLocaleString()} timeline entries` :
`${filteredRequests.length.toLocaleString()} requests found`}
</div>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '10px',
marginBottom: '20px'
}}>
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
style={{
padding: '8px 16px',
border: '1px solid #ced4da',
borderRadius: '4px',
background: currentPage === 1 ? '#e9ecef' : '#fff',
cursor: currentPage === 1 ? 'not-allowed' : 'pointer'
}}
>
Previous
</button>
<span style={{ margin: '0 15px', fontSize: '14px' }}>
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
style={{
padding: '8px 16px',
border: '1px solid #ced4da',
borderRadius: '4px',
background: currentPage === totalPages ? '#e9ecef' : '#fff',
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer'
}}
>
Next
</button>
</div>
)}
{/* 3D Network Visualization Modal */}
{show3DViewer && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000
}}>
<div style={{
width: '90vw',
height: '90vh',
background: 'white',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '15px 20px',
borderBottom: '1px solid #dee2e6',
borderRadius: '12px 12px 0 0'
}}>
<h3 style={{ margin: '0', color: '#495057', fontSize: '18px' }}>
3D Network Visualization ({filteredRequests.length} requests)
</h3>
<button
onClick={() => setShow3DViewer(false)}
style={{
background: 'transparent',
border: '1px solid #6c757d',
color: '#6c757d',
padding: '8px 12px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Close
</button>
</div>
<div style={{ flex: 1, padding: '10px' }}>
<BabylonViewer httpRequests={filteredRequests} width={window.innerWidth * 0.9} height={window.innerHeight * 0.9 - 100} />
</div>
<div style={{
padding: '15px 20px',
fontSize: '12px',
color: '#6c757d',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '10px',
borderTop: '1px solid #dee2e6',
borderRadius: '0 0 12px 12px',
backgroundColor: '#f8f9fa'
}}>
<div><strong>Legend:</strong></div>
<div> Gray gradient: 2xx Success (darker=lower, lighter=higher)</div>
<div>🟡 Yellow: 3xx Redirects</div>
<div>🟠 Orange: 4xx Client Errors</div>
<div>🔴 Red: 5xx Server Errors</div>
<div><strong>Layout:</strong></div>
<div>🔵 Central sphere: Origin</div>
<div>🏷 Hostname labels: At 12m radius</div>
<div>📦 Request boxes: Start end timeline</div>
<div>📏 Front face: Request start time</div>
<div>📐 Height: 0.1m-5m (content-length)</div>
<div>📊 Depth: Request duration</div>
<div>📚 Overlapping requests stack vertically</div>
<div>🔗 Connection lines to center</div>
<div>👁 Labels always face camera</div>
</div>
</div>
</div>
)}
{/* 3D Timeline Visualization Modal */}
{showTimelineViewer && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000
}}>
<div style={{
width: '90vw',
height: '90vh',
background: 'white',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '15px 20px',
borderBottom: '1px solid #dee2e6',
borderRadius: '12px 12px 0 0'
}}>
<h3 style={{ margin: '0', color: '#495057', fontSize: '18px' }}>
3D Timeline Visualization ({filteredRequests.length} requests)
</h3>
<button
onClick={() => setShowTimelineViewer(false)}
style={{
background: 'transparent',
border: '1px solid #6c757d',
color: '#6c757d',
padding: '8px 12px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Close
</button>
</div>
<div style={{ flex: 1, padding: '10px' }}>
<BabylonTimelineViewer httpRequests={filteredRequests} width={window.innerWidth * 0.9} height={window.innerHeight * 0.9 - 100} />
</div>
<div style={{
padding: '15px 20px',
fontSize: '12px',
color: '#6c757d',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '10px',
borderTop: '1px solid #dee2e6',
borderRadius: '0 0 12px 12px',
backgroundColor: '#f8f9fa'
}}>
<div><strong>Legend:</strong></div>
<div> Gray gradient: 2xx Success (darker=lower, lighter=higher)</div>
<div>🟡 Yellow: 3xx Redirects</div>
<div>🟠 Orange: 4xx Client Errors</div>
<div>🔴 Red: 5xx Server Errors</div>
<div><strong>Timeline Layout:</strong></div>
<div>🔵 Central sphere: Timeline origin</div>
<div>🏷 Hostname labels: At 12m radius</div>
<div>📦 Request boxes: Chronological timeline</div>
<div>📏 Distance from center: Start time</div>
<div>📐 Height: 0.1m-5m (content-length)</div>
<div>📊 Depth: Request duration</div>
<div>📚 Overlapping requests stack vertically</div>
<div>🔗 Connection lines to center</div>
<div>👁 Labels face origin (180° rotated)</div>
</div>
</div>
</div>
)}
{/* Requests Table */}
<div style={{
background: 'white',
border: '1px solid #dee2e6',
borderRadius: '8px',
overflow: 'hidden'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ padding: '8px', textAlign: 'center', borderBottom: '1px solid #dee2e6', fontSize: '12px', width: '30px' }}>Expand</th>
<th style={{ padding: '8px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Method</th>
<th style={{ padding: '8px', textAlign: 'center', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Status</th>
<th style={{ padding: '8px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Type</th>
<th style={{ padding: '8px', textAlign: 'center', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Priority</th>
<th style={{ padding: '8px', textAlign: 'right', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Start Time</th>
<th style={{ padding: '8px', textAlign: 'right', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Queue Time</th>
<th style={{ padding: '8px', textAlign: 'right', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Server Latency</th>
<th style={{ padding: '8px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>URL</th>
<th style={{ padding: '8px', textAlign: 'right', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Duration</th>
<th style={{ padding: '8px', textAlign: 'right', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Size</th>
<th style={{ padding: '8px', textAlign: 'right', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Content-Length</th>
<th style={{ padding: '8px', textAlign: 'center', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Protocol</th>
<th style={{ padding: '8px', textAlign: 'center', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>CDN</th>
<th style={{ padding: '8px', textAlign: 'center', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Cache</th>
</tr>
</thead>
<tbody>
{(showScreenshots ? paginatedTimelineEntries : paginatedRequests.map(req => ({ type: 'request' as const, timestamp: req.timing.start, data: req }))).map((entry, entryIndex) => {
if (entry.type === 'screenshot') {
const screenshot = entry.data as ScreenshotEvent
const firstRequestTime = httpRequests.length > 0 ? httpRequests[0].timing.start : 0
const timeOffset = screenshot.timestamp - firstRequestTime
return (
<tr key={`screenshot-${screenshot.index}`} style={{
backgroundColor: '#f0f8ff',
borderBottom: '2px solid #007bff'
}}>
<td colSpan={13} style={{ padding: '15px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
<div style={{
fontSize: '14px',
fontWeight: 'bold',
color: '#007bff',
minWidth: '120px'
}}>
📸 Screenshot
</div>
<div style={{
fontSize: '12px',
color: '#495057',
fontFamily: 'monospace'
}}>
<strong>Time:</strong> {formatDuration(timeOffset)}
</div>
<img
src={screenshot.screenshot}
alt={`Screenshot at ${formatDuration(timeOffset)}`}
style={{
maxWidth: '200px',
maxHeight: '150px',
border: '2px solid #007bff',
borderRadius: '4px',
cursor: 'pointer'
}}
onClick={() => {
// Open screenshot in new window for full size viewing
const newWindow = window.open('', '_blank')
if (newWindow) {
newWindow.document.write(`
<html>
<head><title>Screenshot at ${formatDuration(timeOffset)}</title></head>
<body style="margin:0;padding:20px;background:#000;display:flex;justify-content:center;align-items:center;min-height:100vh;">
<img src="${screenshot.screenshot}" style="max-width:100%;max-height:100vh;border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.5);" alt="Full size screenshot" />
</body>
</html>
`)
}
}}
/>
<div style={{
fontSize: '11px',
color: '#6c757d',
fontStyle: 'italic'
}}>
Click to view full size
</div>
</div>
</td>
</tr>
)
}
const request = entry.data as HTTPRequest
const isExpanded = expandedRows.has(request.requestId)
return (
<>
<tr key={request.requestId} style={{
borderBottom: '1px solid #f1f3f4',
cursor: 'pointer'
}}
onClick={() => toggleRowExpansion(request.requestId)}
>
<td style={{
padding: '8px',
textAlign: 'center',
fontSize: '12px',
color: '#007bff',
fontWeight: 'bold'
}}>
{isExpanded ? '' : '+'}
</td>
<td style={{
padding: '8px',
fontSize: '12px',
fontFamily: 'monospace',
fontWeight: 'bold',
color: request.method === 'GET' ? '#28a745' : '#007bff'
}}>
{request.method}
</td>
<td style={{
padding: '8px',
fontSize: '12px',
textAlign: 'center',
fontFamily: 'monospace',
fontWeight: 'bold',
color: getStatusColor(request.statusCode)
}}>
{request.statusCode || '-'}
</td>
<td style={{ padding: '8px', fontSize: '12px', color: '#6c757d' }}>
{request.resourceType}
</td>
<td style={{
padding: '8px',
fontSize: '12px',
textAlign: 'center',
fontFamily: 'monospace',
color: getPriorityColor(request.priority),
fontWeight: 'bold'
}}>
{request.priority || '-'}
</td>
<td style={{ padding: '8px', fontSize: '12px', textAlign: 'right', fontFamily: 'monospace', color: '#495057' }}>
{formatDuration(request.timing.startOffset)}
</td>
<td style={{
padding: '8px',
fontSize: '12px',
textAlign: 'right',
fontFamily: 'monospace',
color: request.timing.queueTime && request.timing.queueTime > HIGHLIGHTING_CONFIG.QUEUE_TIME.HIGH_THRESHOLD ? HIGHLIGHTING_CONFIG.COLORS.STATUS.SERVER_ERROR : '#495057',
fontWeight: request.timing.queueTime && request.timing.queueTime > HIGHLIGHTING_CONFIG.QUEUE_TIME.HIGH_THRESHOLD ? 'bold' : 'normal'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{formatDuration(request.timing.queueTime)}
{showQueueAnalysis && request.queueAnalysis && (
<span
title={request.queueAnalysis.description}
style={{ cursor: 'help', fontSize: '14px' }}
>
{getQueueAnalysisIcon(request.queueAnalysis)}
</span>
)}
</div>
</td>
<td style={{
padding: '8px',
fontSize: '12px',
textAlign: 'right',
fontFamily: 'monospace',
...getServerLatencyColor(request.timing.serverLatency),
borderRadius: '4px',
fontWeight: 'bold'
}}>
{formatDuration(request.timing.serverLatency)}
</td>
<td style={{ padding: '8px', fontSize: '11px', maxWidth: '400px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<a href={request.url} target="_blank" rel="noopener noreferrer" style={{ color: '#007bff', textDecoration: 'none' }}>
{truncateUrl(request.url)}
</a>
</td>
<td style={{
padding: '8px',
fontSize: '12px',
textAlign: 'right',
fontFamily: 'monospace',
...getDurationColor(request.timing.duration),
borderRadius: '4px',
fontWeight: 'bold'
}}>
{formatDuration(request.timing.duration)}
</td>
<td style={{
padding: '8px',
fontSize: '12px',
textAlign: 'right',
fontFamily: 'monospace',
...getSizeColor(request.encodedDataLength),
borderRadius: '4px',
fontWeight: 'bold'
}}>
{formatSize(request.encodedDataLength)}
</td>
<td style={{
padding: '8px',
fontSize: '12px',
textAlign: 'right',
fontFamily: 'monospace',
...getSizeColor(request.contentLength),
borderRadius: '4px',
fontWeight: 'bold'
}}>
{request.contentLength ? formatSize(request.contentLength) : '-'}
</td>
<td style={{
padding: '8px',
fontSize: '12px',
textAlign: 'center',
fontFamily: 'monospace',
color: getProtocolColor(request.protocol),
fontWeight: 'bold'
}}>
{request.protocol || '-'}
</td>
<td style={{
padding: '8px',
fontSize: '12px',
textAlign: 'center',
cursor: request.cdnAnalysis ? 'help' : 'default'
}}
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 style={{ padding: '8px', fontSize: '12px', textAlign: 'center' }}>
{request.fromCache ? '💾' : request.connectionReused ? '🔄' : '🌐'}
</td>
</tr>
{/* Expanded Row Details */}
{isExpanded && (
<tr key={`${request.requestId}-expanded`}>
<td colSpan={13} style={{
padding: '15px',
background: '#f8f9fa',
border: '1px solid #e9ecef'
}}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '15px' }}>
{/* Request Details */}
<div style={{ background: 'white', padding: '10px', borderRadius: '4px', border: '1px solid #dee2e6' }}>
<h4 style={{ margin: '0 0 8px 0', color: '#495057', fontSize: '14px' }}>Request Details</h4>
<div style={{ fontSize: '12px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div><strong>Request ID:</strong> {request.requestId}</div>
<div><strong>Method:</strong> {request.method}</div>
<div><strong>Priority:</strong> {request.priority}</div>
<div><strong>MIME Type:</strong> {request.mimeType || '-'}</div>
<div><strong>Content-Length:</strong> {request.contentLength ? formatSize(request.contentLength) : '-'}</div>
<div><strong>From Cache:</strong> {request.fromCache ? 'Yes' : 'No'}</div>
<div><strong>Connection Reused:</strong> {request.connectionReused ? 'Yes' : 'No'}</div>
</div>
</div>
{/* Network Timing */}
<div style={{ background: 'white', padding: '10px', borderRadius: '4px', border: '1px solid #dee2e6' }}>
<h4 style={{ margin: '0 0 8px 0', color: '#495057', fontSize: '14px' }}>Network Timing</h4>
<div style={{ fontSize: '12px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div><strong>Start Time:</strong> {formatDuration(request.timing.startOffset)}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<strong>Queue Time:</strong> {formatDuration(request.timing.queueTime)}
{request.queueAnalysis && (
<span title={request.queueAnalysis.description} style={{ cursor: 'help' }}>
{getQueueAnalysisIcon(request.queueAnalysis)}
</span>
)}
</div>
<div><strong>Server Latency:</strong> {formatDuration(request.timing.serverLatency)}</div>
<div><strong>Total Duration:</strong> {formatDuration(request.timing.duration)}</div>
<div><strong>Network Duration:</strong> {formatDuration(request.timing.networkDuration)}</div>
{request.timing.dnsStart !== -1 && request.timing.dnsEnd !== -1 && (
<div><strong>DNS:</strong> {formatDuration((request.timing.dnsEnd || 0) - (request.timing.dnsStart || 0))}</div>
)}
{request.timing.connectStart !== -1 && request.timing.connectEnd !== -1 && (
<div><strong>Connect:</strong> {formatDuration((request.timing.connectEnd || 0) - (request.timing.connectStart || 0))}</div>
)}
{request.timing.sslStart !== -1 && request.timing.sslEnd !== -1 && (
<div><strong>SSL:</strong> {formatDuration((request.timing.sslEnd || 0) - (request.timing.sslStart || 0))}</div>
)}
{request.timing.sendStart !== -1 && request.timing.sendEnd !== -1 && (
<div><strong>Send:</strong> {formatDuration((request.timing.sendEnd || 0) - (request.timing.sendStart || 0))}</div>
)}
{request.timing.receiveHeadersStart !== -1 && request.timing.receiveHeadersEnd !== -1 && (
<div><strong>Receive Headers:</strong> {formatDuration((request.timing.receiveHeadersEnd || 0) - (request.timing.receiveHeadersStart || 0))}</div>
)}
</div>
</div>
{/* Queue Analysis */}
{request.queueAnalysis && request.queueAnalysis.reason !== 'unknown' && (
<div style={{ background: 'white', padding: '10px', borderRadius: '4px', border: '1px solid #dee2e6' }}>
<h4 style={{ margin: '0 0 8px 0', color: '#495057', fontSize: '14px' }}>
Queue Analysis {getQueueAnalysisIcon(request.queueAnalysis)}
</h4>
<div style={{ fontSize: '12px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div><strong>Reason:</strong> {request.queueAnalysis.description}</div>
<div><strong>Concurrent Requests:</strong> {request.queueAnalysis.concurrentRequests}</div>
{request.queueAnalysis.relatedRequests && request.queueAnalysis.relatedRequests.length > 0 && (
<div>
<strong>Related Request IDs:</strong>{' '}
<span style={{ fontFamily: 'monospace', fontSize: '11px' }}>
{request.queueAnalysis.relatedRequests.join(', ')}
{request.queueAnalysis.concurrentRequests > request.queueAnalysis.relatedRequests.length &&
` (+${request.queueAnalysis.concurrentRequests - request.queueAnalysis.relatedRequests.length} more)`}
</span>
</div>
)}
</div>
</div>
)}
{/* CDN Analysis */}
{request.cdnAnalysis && request.cdnAnalysis.provider !== 'unknown' && (
<div style={{ background: 'white', padding: '10px', borderRadius: '4px', border: '1px solid #dee2e6' }}>
<h4 style={{ margin: '0 0 8px 0', color: '#495057', fontSize: '14px' }}>
CDN Analysis {getCDNIcon(request.cdnAnalysis)}
</h4>
<div style={{ fontSize: '12px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div><strong>Provider:</strong> {getCDNDisplayName(request.cdnAnalysis.provider)}</div>
<div><strong>Source:</strong> {request.cdnAnalysis.isEdge ? 'Edge Server' : 'Origin Server'}</div>
{request.cdnAnalysis.cacheStatus && request.cdnAnalysis.cacheStatus !== 'unknown' && (
<div><strong>Cache Status:</strong> {request.cdnAnalysis.cacheStatus.toUpperCase()}</div>
)}
{request.cdnAnalysis.edgeLocation && (
<div><strong>Edge Location:</strong> {request.cdnAnalysis.edgeLocation}</div>
)}
<div><strong>Confidence:</strong> {(request.cdnAnalysis.confidence * 100).toFixed(0)}%</div>
<div><strong>Detection Method:</strong> {request.cdnAnalysis.detectionMethod}</div>
{/* Debug info for canadiantire.ca requests */}
{request.hostname.includes('canadiantire.ca') && (
<div style={{
marginTop: '8px',
padding: '8px',
background: '#fff3cd',
border: '1px solid #ffeaa7',
borderRadius: '4px'
}}>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
Debug - CDN Detection Analysis:
</div>
<div style={{ marginBottom: '6px', fontSize: '11px' }}>
<strong>Current Detection:</strong> {request.cdnAnalysis?.provider}
(confidence: {((request.cdnAnalysis?.confidence || 0) * 100).toFixed(0)}%)
</div>
<div style={{ fontWeight: 'bold', fontSize: '10px', marginBottom: '2px' }}>
All CDN-Related Headers:
</div>
{request.responseHeaders && request.responseHeaders.map((header, idx) => {
const headerName = header.name.toLowerCase()
// Show all potentially CDN-related headers
if (headerName.includes('akamai') ||
headerName.includes('fastly') ||
headerName.includes('server') ||
headerName.includes('via') ||
headerName.includes('x-cache') ||
headerName.includes('x-serial') ||
headerName.includes('x-served-by') ||
headerName.includes('cf-') ||
headerName.includes('x-amz-cf') ||
headerName.includes('azure') ||
headerName.includes('x-goog') ||
headerName.includes('cdn') ||
headerName.includes('edge') ||
headerName.includes('cache')) {
const isAkamaiIndicator = headerName.includes('akamai') ||
headerName.includes('x-serial') ||
(headerName === 'x-cache' && header.value.includes('tcp_')) ||
(headerName === 'x-served-by' && header.value.includes('cache-'))
return (
<div key={idx} style={{
fontSize: '9px',
fontFamily: 'monospace',
backgroundColor: isAkamaiIndicator ? '#d1ecf1' : 'transparent',
padding: isAkamaiIndicator ? '1px 3px' : '0',
borderRadius: isAkamaiIndicator ? '2px' : '0',
marginBottom: '1px'
}}>
<span style={{
color: isAkamaiIndicator ? '#0c5460' : '#007bff',
fontWeight: 'bold'
}}>
{header.name}:
</span> {header.value}
{isAkamaiIndicator && (
<span style={{ color: '#0c5460', fontWeight: 'bold', marginLeft: '5px' }}>
AKAMAI
</span>
)}
</div>
)
}
return null
})}
</div>
)}
</div>
</div>
)}
{/* Response Headers */}
{request.responseHeaders && request.responseHeaders.length > 0 && (
<div style={{
background: 'white',
padding: '10px',
borderRadius: '4px',
border: '1px solid #dee2e6',
gridColumn: '1 / -1'
}}>
<h4 style={{ margin: '0 0 8px 0', color: '#495057', fontSize: '14px' }}>
Response Headers ({request.responseHeaders.length})
</h4>
<div style={{
maxHeight: '150px',
overflowY: 'auto',
fontSize: '11px',
fontFamily: 'monospace',
background: '#f8f9fa',
padding: '8px',
borderRadius: '3px'
}}>
{request.responseHeaders.map((header, index) => (
<div key={index} style={{
marginBottom: '2px',
display: 'grid',
gridTemplateColumns: '150px 1fr',
gap: '10px'
}}>
<div style={{ color: '#007bff', fontWeight: 'bold' }}>
{header.name}:
</div>
<div style={{ color: '#495057', wordBreak: 'break-all' }}>
{header.value}
</div>
</div>
))}
</div>
</div>
)}
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
{(showScreenshots ? paginatedTimelineEntries.length === 0 : paginatedRequests.length === 0) && (
<div style={{
textAlign: 'center',
padding: '40px',
color: '#6c757d',
fontSize: '16px'
}}>
{showScreenshots ?
'No timeline entries found matching the current filters' :
'No HTTP requests found matching the current filters'}
</div>
)}
</div>
)
}