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:
parent
1f12b143ef
commit
8a791a1186
9
package-lock.json
generated
9
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
23
src/App.tsx
23
src/App.tsx
@ -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} />
|
||||
)}
|
||||
|
458
src/BabylonTimelineViewer.tsx
Normal file
458
src/BabylonTimelineViewer.tsx
Normal 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' }}
|
||||
/>
|
||||
)
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
||||
)}
|
||||
|
Loading…
Reference in New Issue
Block a user