- 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>
2004 lines
83 KiB
TypeScript
2004 lines
83 KiB
TypeScript
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>
|
||
)
|
||
} |