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>
This commit is contained in:
Michael Mainguy 2025-08-07 09:21:45 -05:00
parent 1f12b143ef
commit 8a791a1186
6 changed files with 1229 additions and 80 deletions

9
package-lock.json generated
View File

@ -10,7 +10,8 @@
"dependencies": {
"babylonjs": "^8.21.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"ssim.js": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
@ -3041,6 +3042,12 @@
"node": ">=0.10.0"
}
},
"node_modules/ssim.js": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/ssim.js/-/ssim.js-3.5.0.tgz",
"integrity": "sha512-Aj6Jl2z6oDmgYFFbQqK7fght19bXdOxY7Tj03nF+03M9gCBAjeIiO8/PlEGMfKDwYpw4q6iBqVq2YuREorGg/g==",
"license": "MIT"
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",

View File

@ -12,7 +12,8 @@
"dependencies": {
"babylonjs": "^8.21.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"ssim.js": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",

View File

@ -1,6 +1,5 @@
import { useState, useEffect } from 'react'
import './App.css'
import BabylonViewer from './BabylonViewer'
import TraceViewer from './components/TraceViewer'
import PhaseViewer from './components/PhaseViewer'
import HTTPRequestViewer from './components/HTTPRequestViewer'
@ -8,7 +7,7 @@ import TraceUpload from './components/TraceUpload'
import TraceSelector from './components/TraceSelector'
import { traceDatabase } from './utils/traceDatabase'
type AppView = '3d' | 'trace' | 'phases' | 'http'
type AppView = 'trace' | 'phases' | 'http'
type AppMode = 'selector' | 'upload' | 'analysis'
function App() {
@ -123,20 +122,6 @@ function App() {
</div>
<nav style={{ display: 'flex', gap: '10px' }}>
<button
onClick={() => setCurrentView('3d')}
style={{
background: currentView === '3d' ? '#007bff' : '#6c757d',
color: 'white',
border: 'none',
padding: '8px 16px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
3D Viewer
</button>
<button
onClick={() => setCurrentView('trace')}
style={{
@ -182,12 +167,6 @@ function App() {
</nav>
</div>
{currentView === '3d' && (
<div style={{ width: '100%', height: '600px' }}>
<BabylonViewer />
</div>
)}
{currentView === 'trace' && (
<TraceViewer traceId={selectedTraceId} />
)}

View File

@ -0,0 +1,458 @@
import { useEffect, useRef } from 'react'
import {
Engine,
Scene,
UniversalCamera,
Vector3,
HemisphericLight,
MeshBuilder,
StandardMaterial,
Color3,
DynamicTexture
} from 'babylonjs'
// Helper function to create timeline labels
function createTimelineLabel(
scene: Scene,
labelId: string,
position: Vector3,
actualTimeOffset: number
): void {
// Calculate time conversions
const actualTimeMs = actualTimeOffset / 1000 // Convert to milliseconds
const actualTimeSeconds = actualTimeOffset / 1000000 // Convert to seconds
// Format time label based on magnitude (assuming microseconds input)
let timeLabelText = ''
if (actualTimeSeconds >= 1) {
// If >= 1 second, show in seconds
timeLabelText = `${actualTimeSeconds.toFixed(1)}s`
} else if (actualTimeMs >= 1) {
// If >= 1 millisecond, show in milliseconds
timeLabelText = `${Math.round(actualTimeMs)}ms`
} else {
// If < 1 millisecond, show in microseconds
timeLabelText = `${Math.round(actualTimeOffset)}μs`
}
// Create label texture
const labelTexture = new DynamicTexture(`timeLabel_${labelId}`, { width: 80, height: 32 }, scene)
labelTexture.hasAlpha = true
labelTexture.drawText(timeLabelText, null, null, '12px Arial', 'white', 'rgba(0,0,0,0.5)', true)
// Create label plane
const timeLabelPlane = MeshBuilder.CreatePlane(`timeLabelPlane_${labelId}`, { size: 0.5 }, scene)
timeLabelPlane.position = position
// Create and apply material
const timeLabelMaterial = new StandardMaterial(`timeLabelMaterial_${labelId}`, scene)
timeLabelMaterial.diffuseTexture = labelTexture
timeLabelMaterial.hasAlpha = true
timeLabelMaterial.backFaceCulling = false
timeLabelPlane.material = timeLabelMaterial
}
interface HTTPRequest {
requestId: string
url: string
hostname: string
method: string
resourceType: string
priority: string
statusCode?: number
mimeType?: string
protocol?: string
timing: {
start: number
startOffset?: number
end?: number
duration?: number
queueTime?: number
serverLatency?: number
}
encodedDataLength?: number
contentLength?: number
fromCache: boolean
connectionReused: boolean
}
interface BabylonTimelineViewerProps {
width?: number
height?: number
httpRequests?: HTTPRequest[]
}
export default function BabylonTimelineViewer({ width = 800, height = 600, httpRequests = [] }: BabylonTimelineViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<Engine | null>(null)
const sceneRef = useRef<Scene | null>(null)
useEffect(() => {
if (!canvasRef.current) return
const canvas = canvasRef.current
const engine = new Engine(canvas, true)
engineRef.current = engine
const scene = new Scene(engine)
sceneRef.current = scene
// Create universal camera for enhanced keyboard and mouse navigation
const camera = new UniversalCamera('camera1', new Vector3(0, 5, -10), scene)
camera.attachControl(canvas, true)
// Set camera target to look at the origin
camera.setTarget(new Vector3(0, 0, 0))
// Configure camera movement speed and sensitivity
camera.speed = 0.5
camera.angularSensibility = 2000
// Enable WASD keys explicitly
camera.keysUp = [87] // W key
camera.keysDown = [83] // S key
camera.keysLeft = [65] // A key
camera.keysRight = [68] // D key
scene.activeCamera = camera
const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene)
light.intensity = 0.7
// Create ground plane oriented along the Z-axis timeline
const ground = MeshBuilder.CreateGround('ground', { width: 20, height: 12 }, scene)
ground.rotation.x = -Math.PI / 2 // Rotate to lie along Z-axis
ground.position = new Vector3(0, 0, 5) // Center at middle of timeline
const groundMaterial = new StandardMaterial('groundMaterial', scene)
groundMaterial.diffuseColor = new Color3(0.2, 0.2, 0.2)
groundMaterial.alpha = 0.3
ground.material = groundMaterial
// Create timeline start marker at Z = 0
const startMarker = MeshBuilder.CreateSphere('timelineStart', { diameter: 0.3 }, scene)
startMarker.position = new Vector3(0, 0, 0)
const startMaterial = new StandardMaterial('startMaterial', scene)
startMaterial.diffuseColor = new Color3(0.2, 0.8, 0.2) // Green for start
startMaterial.specularColor = new Color3(0.5, 1, 0.5)
startMarker.material = startMaterial
// Create timeline end marker at Z = 10
const endMarker = MeshBuilder.CreateSphere('timelineEnd', { diameter: 0.3 }, scene)
endMarker.position = new Vector3(0, 0, 10)
const endMaterial = new StandardMaterial('endMaterial', scene)
endMaterial.diffuseColor = new Color3(0.8, 0.2, 0.2) // Red for end
endMaterial.specularColor = new Color3(1, 0.5, 0.5)
endMarker.material = endMaterial
// Visualize HTTP requests in timeline swimlanes by hostname
if (httpRequests && httpRequests.length > 0) {
// Group requests by hostname
const requestsByHostname = new Map<string, HTTPRequest[]>()
httpRequests.forEach(request => {
if (!requestsByHostname.has(request.hostname)) {
requestsByHostname.set(request.hostname, [])
}
requestsByHostname.get(request.hostname)!.push(request)
})
// Find min and max start times for Z-axis timeline normalization
const startTimes = httpRequests.map(req => req.timing.startOffset || 0)
const endTimes = httpRequests.map(req => (req.timing.startOffset || 0) + (req.timing.duration || 0))
const minStartTime = Math.min(...startTimes)
const maxEndTime = Math.max(...endTimes)
const totalTimeRange = maxEndTime - minStartTime
// Debug: Check the actual timing values (assuming microseconds)
console.log(`Timeline debug: minStart=${minStartTime}μs, maxEnd=${maxEndTime}μs, totalRange=${totalTimeRange}μs`)
console.log(`Timeline debug: totalRange in ms=${totalTimeRange/1000}ms, in seconds=${totalTimeRange/1000000}s`)
console.log(`Sample request timing:`, httpRequests[0]?.timing)
// Find min and max content-length values for height normalization
const contentLengths = httpRequests
.map(req => req.contentLength || 0)
.filter(size => size > 0) // Only consider requests with content-length
const minContentLength = contentLengths.length > 0 ? Math.min(...contentLengths) : 1
const maxContentLength = contentLengths.length > 0 ? Math.max(...contentLengths) : 1
const contentLengthRange = maxContentLength - minContentLength
const hostnames = Array.from(requestsByHostname.keys())
// Sort hostnames by their earliest request start time
const hostnamesWithStartTimes = hostnames.map(hostname => {
const requests = requestsByHostname.get(hostname)!
const earliestStartTime = Math.min(...requests.map(req => req.timing.startOffset || 0))
return { hostname, earliestStartTime }
})
hostnamesWithStartTimes.sort((a, b) => a.earliestStartTime - b.earliestStartTime)
const hostCount = hostnames.length
const minZ = 0 // Timeline start at Z = 0
const maxZ = 20 // Timeline end at Z = 10
const minHeight = 0.1 // Minimum box height (0.1 meters)
const maxHeight = 2 // Maximum box height (5 meters)
const swimlaneSpacing = 1.5 // Spacing between swimlanes on X-axis
// Calculate the full width needed to span all swimlanes
const totalWidth = (hostCount - 1) * swimlaneSpacing + 4 // Add padding on both sides
// Create timeline grid lines at regular intervals across the normalized 0-10 timeline
const gridLineCount = 10 // Create 10 grid lines across the timeline
for (let i = 0; i <= gridLineCount; i++) {
const zPosition = minZ + (i / gridLineCount) * (maxZ - minZ) // Evenly spaced from 0 to 10
// Calculate the actual time this position represents
const actualTimeOffset = (i / gridLineCount) * totalTimeRange // This is in microseconds
const actualTimeMs = actualTimeOffset / 1000 // Convert to milliseconds
const actualTimeSeconds = actualTimeOffset / 1000000 // Convert to seconds
// Debug: Let's see what the actual values are
console.log(`Grid line ${i}: offset=${actualTimeOffset}μs, ms=${actualTimeMs}ms, seconds=${actualTimeSeconds}s, totalRange=${totalTimeRange}μs`)
// Create grid line spanning the full width of all content
const linePoints = [
new Vector3(-totalWidth / 2, 0, zPosition),
new Vector3(totalWidth / 2, 0, zPosition)
]
const gridLine = MeshBuilder.CreateLines(`gridLine_${i}`, { points: linePoints }, scene)
const gridMaterial = new StandardMaterial(`gridMaterial_${i}`, scene)
gridMaterial.diffuseColor = new Color3(0.5, 0.5, 0.5)
gridMaterial.alpha = 0.3
// Add timeline label for each grid line
createTimelineLabel(scene, i.toString(), new Vector3(-0.5, -0.5, zPosition), actualTimeOffset)
}
hostnamesWithStartTimes.forEach(({ hostname }, sortedIndex) => {
// Calculate X position alternating left/right from origin, with distance increasing by start time order
// Index 0 (earliest) -> X = 0, Index 1 -> X = -2, Index 2 -> X = 2, Index 3 -> X = -4, Index 4 -> X = 4, etc.
let xPosition = 0
if (sortedIndex > 0) {
const distance = Math.ceil(sortedIndex / 2) * swimlaneSpacing
xPosition = (sortedIndex % 2 === 1) ? -distance : distance
}
// Create hostname label at the start of each swimlane
const labelTexture = new DynamicTexture(`hostLabel_${sortedIndex}`, { width: 256, height: 64 }, scene)
labelTexture.hasAlpha = true
labelTexture.drawText(
hostname,
null, null,
'16px Arial',
'white',
'rgba(0,0,0,0.7)',
true
)
const hostLabel = MeshBuilder.CreatePlane(`hostLabelPlane_${sortedIndex}`, { size: 1.5 }, scene)
hostLabel.position = new Vector3(xPosition, -1, -1) // Position at Y = -1, Z = -1 (below ground, before timeline start)
const labelMaterial = new StandardMaterial(`hostLabelMaterial_${sortedIndex}`, scene)
labelMaterial.diffuseTexture = labelTexture
labelMaterial.hasAlpha = true
labelMaterial.backFaceCulling = false
hostLabel.material = labelMaterial
// Get requests for this hostname and sort by start time
const hostnameRequests = requestsByHostname.get(hostname)!
hostnameRequests.sort((a, b) => (a.timing.startOffset || 0) - (b.timing.startOffset || 0))
// Track placed boxes for this hostname to handle stacking
const placedBoxes: Array<{
startZ: number
endZ: number
height: number
yPosition: number
}> = []
// Create boxes for each request from this hostname
hostnameRequests.forEach((request, requestIndex) => {
// Calculate Z positions based on timeline (start time to end time)
const startTime = request.timing.startOffset || 0
const duration = request.timing.duration || 1000 // Default 1ms if no duration
const endTime = startTime + duration
// Normalize to Z-axis range (0 to 10)
const normalizedStartTime = totalTimeRange > 0 ? (startTime - minStartTime) / totalTimeRange : 0
const normalizedEndTime = totalTimeRange > 0 ? (endTime - minStartTime) / totalTimeRange : 0
const startZ = minZ + (normalizedStartTime * (maxZ - minZ))
const endZ = minZ + (normalizedEndTime * (maxZ - minZ))
// Calculate box depth (duration) and center Z position
const boxDepth = Math.max(0.05, endZ - startZ) // Minimum depth of 0.05m
const centerZ = startZ + (boxDepth / 2)
// Calculate height based on content-length
const contentLength = request.contentLength || 0
let boxHeight = minHeight
if (contentLength > 0 && contentLengthRange > 0) {
const normalizedSize = (contentLength - minContentLength) / contentLengthRange
boxHeight = minHeight + (normalizedSize * (maxHeight - minHeight))
}
// Find the highest Y position for overlapping boxes in this swimlane
let stackHeight = 0
for (const placedBox of placedBoxes) {
// Check if this request overlaps with a placed box on the timeline (Z-axis)
if (startZ < placedBox.endZ && endZ > placedBox.startZ) {
// This box overlaps, so we need to stack it above
stackHeight = Math.max(stackHeight, placedBox.yPosition)
}
}
// Calculate Y position (center of the box) - stack height is the bottom of where this box should be
const yPosition = stackHeight + (boxHeight / 2)
// Calculate server latency and network time portions
const serverLatency = request.timing.serverLatency || 0
const networkTime = duration - serverLatency
// Calculate proportional depths for server vs network time
const serverLatencyRatio = serverLatency > 0 ? serverLatency / duration : 0
const networkTimeRatio = networkTime > 0 ? networkTime / duration : 1
const serverDepth = boxDepth * serverLatencyRatio
const networkDepth = boxDepth * networkTimeRatio
// Calculate color based on vertical position (same gradient for both boxes)
const verticalColorIntensity = Math.min(1.0, Math.max(0.3, (stackHeight + boxHeight) / 8.0))
// Create server time box (gray) - positioned at the start of the request
if (serverDepth > 0.01) { // Only create if server time is significant
const serverCenterZ = startZ + (serverDepth / 2)
const serverBox = MeshBuilder.CreateBox(`serverBox_${sortedIndex}_${requestIndex}`, {
width: 0.2,
height: boxHeight,
depth: serverDepth
}, scene)
serverBox.position = new Vector3(xPosition, yPosition, serverCenterZ)
// Gray material with gradient and 50% opacity
const serverMaterial = new StandardMaterial(`serverMaterial_${sortedIndex}_${requestIndex}`, scene)
serverMaterial.diffuseColor = new Color3(verticalColorIntensity * 0.5, verticalColorIntensity * 0.5, verticalColorIntensity * 0.5)
serverMaterial.alpha = 0.5 // 50% opacity for server time boxes
if (request.fromCache) {
serverMaterial.alpha = 0.35 // Reduce further for cached requests (0.5 * 0.7)
}
serverBox.material = serverMaterial
}
// Create network time box (blue) - positioned after server time
const networkStartZ = startZ + serverDepth
const networkCenterZ = networkStartZ + (networkDepth / 2)
const networkBox = MeshBuilder.CreateBox(`networkBox_${sortedIndex}_${requestIndex}`, {
width: 0.2,
height: boxHeight,
depth: networkDepth
}, scene)
networkBox.position = new Vector3(xPosition, yPosition, networkCenterZ)
// Add this box to the placed boxes list for future overlap checks
placedBoxes.push({
startZ,
endZ,
height: boxHeight,
yPosition: stackHeight + boxHeight
})
// Blue material with gradient and status code coloring
const networkMaterial = new StandardMaterial(`networkMaterial_${sortedIndex}_${requestIndex}`, scene)
if (request.statusCode) {
if (request.statusCode >= 200 && request.statusCode < 300) {
// Blue with gradient for success
networkMaterial.diffuseColor = new Color3(0, 0, verticalColorIntensity)
} else if (request.statusCode >= 300 && request.statusCode < 400) {
networkMaterial.diffuseColor = new Color3(1, 1, 0) // Yellow for redirects
} else if (request.statusCode >= 400 && request.statusCode < 500) {
networkMaterial.diffuseColor = new Color3(1, 0.5, 0) // Orange for client errors
} else if (request.statusCode >= 500) {
networkMaterial.diffuseColor = new Color3(1, 0, 0) // Red for server errors
}
} else {
networkMaterial.diffuseColor = new Color3(0, 0, 0.5) // Dark blue for unknown
}
// Add transparency for cached requests
if (request.fromCache) {
networkMaterial.alpha = 0.7
}
networkBox.material = networkMaterial
// Add yellow queue time line if queue time exists
if (request.timing.queueTime && request.timing.queueTime > 0) {
// Calculate queue start time (when request was first queued - before the actual request start)
const queueStartTime = startTime - request.timing.queueTime
const queueEndTime = startTime // Queue ends when actual request processing starts
// Normalize queue start and end times to Z-axis
const normalizedQueueStartTime = totalTimeRange > 0 ? Math.max(0, (queueStartTime - minStartTime) / totalTimeRange) : 0
const normalizedQueueEndTime = totalTimeRange > 0 ? (queueEndTime - minStartTime) / totalTimeRange : 0
const queueStartZ = minZ + (normalizedQueueStartTime * (maxZ - minZ))
const queueEndZ = minZ + (normalizedQueueEndTime * (maxZ - minZ))
// Calculate queue line depth (from queue start to queue end)
const queueLineDepth = Math.max(0.01, queueEndZ - queueStartZ) // Minimum depth of 0.01m
const queueLineCenterZ = queueStartZ + (queueLineDepth / 2)
// Create yellow queue time line positioned slightly above the request box
const queueLine = MeshBuilder.CreateBox(`queueLine_${sortedIndex}_${requestIndex}`, {
width: 0.05, // Thin line width
height: 0.05, // Thin line height
depth: queueLineDepth
}, scene)
// Position queue line at the same vertical center as the request box
queueLine.position = new Vector3(xPosition, yPosition, queueLineCenterZ)
// Create yellow material for queue time with 25% opacity
const queueMaterial = new StandardMaterial(`queueMaterial_${sortedIndex}_${requestIndex}`, scene)
queueMaterial.diffuseColor = new Color3(1, 1, 0) // Yellow color
queueMaterial.emissiveColor = new Color3(0.2, 0.2, 0) // Slight glow
queueMaterial.alpha = 0.25 // 25% opacity for queue time boxes
queueLine.material = queueMaterial
}
})
// Create a swimlane line from timeline start to end for visual reference
const swimlaneLinePoints = [new Vector3(xPosition, 0, minZ), new Vector3(xPosition, 0, maxZ)]
const swimlaneLine = MeshBuilder.CreateLines(`swimlaneLine_${sortedIndex}`, { points: swimlaneLinePoints }, scene)
const lineMaterial = new StandardMaterial(`swimlaneLineMaterial_${sortedIndex}`, scene)
lineMaterial.diffuseColor = new Color3(0.4, 0.4, 0.4)
lineMaterial.alpha = 0.3
})
}
engine.runRenderLoop(() => {
scene.render()
})
const handleResize = () => {
engine.resize()
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
engine.dispose()
}
}, [httpRequests])
return (
<canvas
ref={canvasRef}
width={width}
height={height}
style={{ width: '100%', height: '100%', display: 'block' }}
/>
)
}

View File

@ -7,15 +7,42 @@ import {
HemisphericLight,
MeshBuilder,
StandardMaterial,
Color3
Color3,
Mesh,
DynamicTexture
} from 'babylonjs'
interface HTTPRequest {
requestId: string
url: string
hostname: string
method: string
resourceType: string
priority: string
statusCode?: number
mimeType?: string
protocol?: string
timing: {
start: number
startOffset?: number
end?: number
duration?: number
queueTime?: number
serverLatency?: number
}
encodedDataLength?: number
contentLength?: number
fromCache: boolean
connectionReused: boolean
}
interface BabylonViewerProps {
width?: number
height?: number
httpRequests?: HTTPRequest[]
}
export default function BabylonViewer({ width = 800, height = 600 }: BabylonViewerProps) {
export default function BabylonViewer({ width = 800, height = 600, httpRequests = [] }: BabylonViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<Engine | null>(null)
const sceneRef = useRef<Scene | null>(null)
@ -30,26 +57,217 @@ export default function BabylonViewer({ width = 800, height = 600 }: BabylonView
const scene = new Scene(engine)
sceneRef.current = scene
const camera = new ArcRotateCamera('camera1', -Math.PI / 2, Math.PI / 2.5, 10, Vector3.Zero(), scene)
const camera = new ArcRotateCamera('camera1', -Math.PI / 2, Math.PI / 2.5, 25, Vector3.Zero(), scene)
camera.attachControl(canvas, true)
scene.activeCamera = camera
const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene)
light.intensity = 0.7
const sphere = MeshBuilder.CreateSphere('sphere', { diameter: 2 }, scene)
sphere.position.y = 1
const ground = MeshBuilder.CreateGround('ground', { width: 6, height: 6 }, scene)
const sphereMaterial = new StandardMaterial('sphereMaterial', scene)
sphereMaterial.diffuseColor = new Color3(1, 0, 1)
sphere.material = sphereMaterial
// Create ground plane for reference
const ground = MeshBuilder.CreateGround('ground', { width: 50, height: 30 }, scene)
const groundMaterial = new StandardMaterial('groundMaterial', scene)
groundMaterial.diffuseColor = new Color3(0.5, 0.5, 0.5)
groundMaterial.diffuseColor = new Color3(0.2, 0.2, 0.2)
groundMaterial.alpha = 0.3
ground.material = groundMaterial
// Create central sphere at origin
const centralSphere = MeshBuilder.CreateSphere('centralSphere', { diameter: 0.5 }, scene)
centralSphere.position = Vector3.Zero()
const sphereMaterial = new StandardMaterial('sphereMaterial', scene)
sphereMaterial.diffuseColor = new Color3(0.3, 0.3, 0.8) // Blue sphere
sphereMaterial.specularColor = new Color3(0.5, 0.5, 1)
centralSphere.material = sphereMaterial
// Visualize HTTP requests as 3D objects arranged radially by hostname
if (httpRequests && httpRequests.length > 0) {
// Group requests by hostname
const requestsByHostname = new Map<string, HTTPRequest[]>()
httpRequests.forEach(request => {
if (!requestsByHostname.has(request.hostname)) {
requestsByHostname.set(request.hostname, [])
}
requestsByHostname.get(request.hostname)!.push(request)
})
// Find min and max start times for normalization
const startTimes = httpRequests.map(req => req.timing.startOffset || 0)
const minStartTime = Math.min(...startTimes)
const maxStartTime = Math.max(...startTimes)
const timeRange = maxStartTime - minStartTime
// Find min and max content-length values for height normalization
const contentLengths = httpRequests
.map(req => req.contentLength || 0)
.filter(size => size > 0) // Only consider requests with content-length
const minContentLength = contentLengths.length > 0 ? Math.min(...contentLengths) : 1
const maxContentLength = contentLengths.length > 0 ? Math.max(...contentLengths) : 1
const contentLengthRange = maxContentLength - minContentLength
// Find min and max duration values for depth normalization
const durations = httpRequests
.map(req => req.timing.duration || 0)
.filter(duration => duration > 0)
const minDuration = durations.length > 0 ? Math.min(...durations) : 1000
const maxDuration = durations.length > 0 ? Math.max(...durations) : 1000
const durationRange = maxDuration - minDuration
const hostnames = Array.from(requestsByHostname.keys())
const hostCount = hostnames.length
const labelRadius = 12 // Distance from center for hostname labels
const minDistance = 1 // Minimum distance from center (1 meter)
const maxDistance = 10 // Maximum distance from center (10 meters)
const minHeight = 0.1 // Minimum box height (0.1 meters)
const maxHeight = 5 // Maximum box height (5 meters)
const minDepth = 0.05 // Minimum box depth (0.05 meters)
const maxDepth = 2 // Maximum box depth (2 meters)
hostnames.forEach((hostname, hostIndex) => {
// Calculate radial position for this hostname
const angle = (hostIndex / hostCount) * 2 * Math.PI
const labelX = labelRadius * Math.cos(angle)
const labelZ = labelRadius * Math.sin(angle)
// Create hostname label that always faces camera
const labelTexture = new DynamicTexture(`hostLabel_${hostIndex}`, { width: 256, height: 64 }, scene)
labelTexture.hasAlpha = true
labelTexture.drawText(
hostname,
null, null,
'16px Arial',
'white',
'rgba(0,0,0,0.7)',
true
)
const hostLabel = MeshBuilder.CreatePlane(`hostLabelPlane_${hostIndex}`, { size: 1.5 }, scene)
hostLabel.position = new Vector3(labelX, 1.5, labelZ)
// Rotate label to face origin on vertical axis only, then add 180 degree rotation
const labelDirectionToOrigin = Vector3.Zero().subtract(hostLabel.position).normalize()
const labelYRotation = Math.atan2(labelDirectionToOrigin.x, labelDirectionToOrigin.z)
hostLabel.rotation.y = labelYRotation + Math.PI // Add 180 degrees (π radians)
const labelMaterial = new StandardMaterial(`hostLabelMaterial_${hostIndex}`, scene)
labelMaterial.diffuseTexture = labelTexture
labelMaterial.hasAlpha = true
labelMaterial.backFaceCulling = false
hostLabel.material = labelMaterial
// Get requests for this hostname and sort by start time
const hostnameRequests = requestsByHostname.get(hostname)!
hostnameRequests.sort((a, b) => (a.timing.startOffset || 0) - (b.timing.startOffset || 0))
// Track placed boxes for this hostname to handle stacking
const placedBoxes: Array<{
startDistance: number
endDistance: number
height: number
yPosition: number
}> = []
// Create boxes for each request from this hostname
hostnameRequests.forEach((request, requestIndex) => {
// Calculate start and end distances from center based on timing
const startTime = request.timing.startOffset || 0
const duration = request.timing.duration || 1000 // Default 1ms if no duration
const endTime = startTime + duration
const normalizedStartTime = timeRange > 0 ? (startTime - minStartTime) / timeRange : 0
const normalizedEndTime = timeRange > 0 ? (endTime - minStartTime) / timeRange : 0
const startDistance = minDistance + (normalizedStartTime * (maxDistance - minDistance))
const endDistance = minDistance + (normalizedEndTime * (maxDistance - minDistance))
// Calculate box depth and center position
const boxDepth = Math.max(minDepth, endDistance - startDistance)
const centerDistance = startDistance + (boxDepth / 2)
// Position box at center distance along the hostname's angle
const boxX = centerDistance * Math.cos(angle)
const boxZ = centerDistance * Math.sin(angle)
// Calculate height based on content-length
const contentLength = request.contentLength || 0
let boxHeight = minHeight
if (contentLength > 0 && contentLengthRange > 0) {
const normalizedSize = (contentLength - minContentLength) / contentLengthRange
boxHeight = minHeight + (normalizedSize * (maxHeight - minHeight))
}
// Find the highest Y position for overlapping boxes
let stackHeight = 0
for (const placedBox of placedBoxes) {
// Check if this request overlaps with a placed box
if (startDistance < placedBox.endDistance && endDistance > placedBox.startDistance) {
// This box overlaps, so we need to stack it above
stackHeight = Math.max(stackHeight, placedBox.yPosition)
}
}
// Calculate Y position (center of the box) - stack height is the bottom of where this box should be
const yPosition = stackHeight + (boxHeight / 2)
// Create box with duration-based depth and content-length based height
const box = MeshBuilder.CreateBox(`request_${hostIndex}_${requestIndex}`, {
width: 0.1,
height: boxHeight,
depth: boxDepth
}, scene)
box.position = new Vector3(boxX, yPosition, boxZ)
// Add this box to the placed boxes list for future overlap checks
// yPosition here represents the top of this box for future stacking
placedBoxes.push({
startDistance,
endDistance,
height: boxHeight,
yPosition: stackHeight + boxHeight
})
// Rotate box to face the origin only on the vertical (Y) axis
const directionToOrigin = Vector3.Zero().subtract(box.position).normalize()
const yRotation = Math.atan2(directionToOrigin.x, directionToOrigin.z)
box.rotation.y = yRotation
// Calculate color based on vertical position for success requests
// Higher boxes are lighter (closer to white), lower boxes are darker
const verticalColorIntensity = Math.min(1.0, Math.max(0.3, (stackHeight + boxHeight) / 8.0))
// Color based on status code
const material = new StandardMaterial(`material_${hostIndex}_${requestIndex}`, scene)
if (request.statusCode) {
if (request.statusCode >= 200 && request.statusCode < 300) {
material.diffuseColor = new Color3(verticalColorIntensity, verticalColorIntensity, verticalColorIntensity)
} else if (request.statusCode >= 300 && request.statusCode < 400) {
material.diffuseColor = new Color3(1, 1, 0) // Yellow for redirects
} else if (request.statusCode >= 400 && request.statusCode < 500) {
material.diffuseColor = new Color3(1, 0.5, 0) // Orange for client errors
} else if (request.statusCode >= 500) {
material.diffuseColor = new Color3(1, 0, 0) // Red for server errors
}
} else {
material.diffuseColor = new Color3(0.5, 0.5, 0.5) // Gray for unknown
}
// Add transparency for cached requests
if (request.fromCache) {
material.alpha = 0.7
}
box.material = material
})
// Create a line from center to hostname label position for visual connection
const linePoints = [Vector3.Zero(), new Vector3(labelX * 0.8, 0, labelZ * 0.8)]
const line = MeshBuilder.CreateLines(`connectionLine_${hostIndex}`, { points: linePoints }, scene)
const lineMaterial = new StandardMaterial(`lineMaterial_${hostIndex}`, scene)
lineMaterial.diffuseColor = new Color3(0.4, 0.4, 0.4)
lineMaterial.alpha = 0.5
})
}
engine.runRenderLoop(() => {
scene.render()
})
@ -64,7 +282,7 @@ export default function BabylonViewer({ width = 800, height = 600 }: BabylonView
window.removeEventListener('resize', handleResize)
engine.dispose()
}
}, [])
}, [httpRequests])
return (
<canvas

View File

@ -1,6 +1,9 @@
import { useState, useMemo } from 'react'
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'
@ -48,6 +51,7 @@ interface HTTPRequest {
end?: number
duration?: number
queueTime?: number
serverLatency?: number
networkDuration?: number
dnsStart?: number
dnsEnd?: number
@ -62,6 +66,7 @@ interface HTTPRequest {
}
responseHeaders?: Array<{ name: string, value: string }>
encodedDataLength?: number
contentLength?: number
fromCache: boolean
connectionReused: boolean
queueAnalysis?: QueueAnalysis
@ -70,6 +75,10 @@ interface HTTPRequest {
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)
@ -86,6 +95,13 @@ const HIGHLIGHTING_CONFIG = {
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
@ -135,6 +151,14 @@ const HIGHLIGHTING_CONFIG = {
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
}
}
}
@ -148,6 +172,30 @@ const getHostnameFromUrl = (url: string): string => {
}
}
// 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
@ -201,23 +249,47 @@ const extractScreenshots = (traceEvents: TraceEvent[]): ScreenshotEvent[] => {
return screenshots.sort((a, b) => a.timestamp - b.timestamp)
}
const findUniqueScreenshots = (screenshots: ScreenshotEvent[]): ScreenshotEvent[] => {
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
// Compare each screenshot with the previous unique one
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]
// Simple comparison - if the base64 data is different, it's a new screenshot
// For better performance on large images, we could compare just a hash or portion
if (current.screenshot !== lastUnique.screenshot) {
uniqueScreenshots.push(current)
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
}
@ -233,8 +305,57 @@ const analyzeCDN = (request: HTTPRequest): CDNAnalysis => {
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
if (headerMap.has('cf-ray') || headerMap.has('cf-cache-status') || hostname.includes('cloudflare')) {
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)'
@ -251,21 +372,6 @@ const analyzeCDN = (request: HTTPRequest): CDNAnalysis => {
edgeLocation = headerMap.get('cf-ray')?.split('-')[1]
}
// Akamai detection
else if (headerMap.has('akamai-cache-status') || headerMap.has('x-cache-key') ||
hostname.includes('akamai') || headerMap.get('server')?.includes('akamai')) {
provider = 'akamai'
confidence = 0.9
detectionMethod = 'headers (akamai-cache-status/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'
}
}
// AWS CloudFront detection
else if (headerMap.has('x-amz-cf-id') || headerMap.has('x-amz-cf-pop') ||
hostname.includes('cloudfront.net')) {
@ -283,12 +389,22 @@ const analyzeCDN = (request: HTTPRequest): CDNAnalysis => {
edgeLocation = headerMap.get('x-amz-cf-pop')
}
// Fastly detection
else if (headerMap.has('fastly-debug-digest') || headerMap.has('x-served-by') ||
hostname.includes('fastly.com') || headerMap.get('via')?.includes('fastly')) {
// 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/x-served-by/via)'
detectionMethod = 'headers (fastly-debug-digest/via/x-cache)'
isEdge = true
const fastlyCache = headerMap.get('x-cache')
@ -439,6 +555,9 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
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(() => {
@ -492,6 +611,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
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
@ -552,6 +682,16 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
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)
@ -581,19 +721,39 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
return sortedRequests
}, [traceData])
// Extract and process screenshots
const screenshots = useMemo(() => {
if (!traceData) return []
const allScreenshots = extractScreenshots(traceData.traceEvents)
console.log('Debug: Found screenshots:', allScreenshots.length)
console.log('Debug: Screenshot events sample:', allScreenshots.slice(0, 3))
const uniqueScreenshots = findUniqueScreenshots(allScreenshots)
console.log('Debug: Unique screenshots:', uniqueScreenshots.length)
return uniqueScreenshots
}, [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
@ -782,6 +942,20 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
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 ''
@ -1057,8 +1231,33 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
</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 && (
{(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' }}>
@ -1067,9 +1266,36 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
checked={showScreenshots}
onChange={(e) => setShowScreenshots(e.target.checked)}
style={{ cursor: 'pointer' }}
disabled={screenshotsLoading}
/>
Show screenshots ({screenshots.length} frames)
{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>
)}
@ -1129,6 +1355,172 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
</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',
@ -1146,9 +1538,11 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
<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>
@ -1296,6 +1690,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
)}
</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)}
@ -1323,6 +1728,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
}}>
{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',
@ -1368,6 +1784,7 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
<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>
@ -1386,6 +1803,7 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
</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 && (
@ -1446,6 +1864,74 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
)}
<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>
)}