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": {
|
"dependencies": {
|
||||||
"babylonjs": "^8.21.1",
|
"babylonjs": "^8.21.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0",
|
||||||
|
"ssim.js": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.30.1",
|
||||||
@ -3041,6 +3042,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
|
@ -12,7 +12,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"babylonjs": "^8.21.1",
|
"babylonjs": "^8.21.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0",
|
||||||
|
"ssim.js": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.30.1",
|
||||||
|
23
src/App.tsx
23
src/App.tsx
@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import BabylonViewer from './BabylonViewer'
|
|
||||||
import TraceViewer from './components/TraceViewer'
|
import TraceViewer from './components/TraceViewer'
|
||||||
import PhaseViewer from './components/PhaseViewer'
|
import PhaseViewer from './components/PhaseViewer'
|
||||||
import HTTPRequestViewer from './components/HTTPRequestViewer'
|
import HTTPRequestViewer from './components/HTTPRequestViewer'
|
||||||
@ -8,7 +7,7 @@ import TraceUpload from './components/TraceUpload'
|
|||||||
import TraceSelector from './components/TraceSelector'
|
import TraceSelector from './components/TraceSelector'
|
||||||
import { traceDatabase } from './utils/traceDatabase'
|
import { traceDatabase } from './utils/traceDatabase'
|
||||||
|
|
||||||
type AppView = '3d' | 'trace' | 'phases' | 'http'
|
type AppView = 'trace' | 'phases' | 'http'
|
||||||
type AppMode = 'selector' | 'upload' | 'analysis'
|
type AppMode = 'selector' | 'upload' | 'analysis'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -123,20 +122,6 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ display: 'flex', gap: '10px' }}>
|
<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
|
<button
|
||||||
onClick={() => setCurrentView('trace')}
|
onClick={() => setCurrentView('trace')}
|
||||||
style={{
|
style={{
|
||||||
@ -182,12 +167,6 @@ function App() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentView === '3d' && (
|
|
||||||
<div style={{ width: '100%', height: '600px' }}>
|
|
||||||
<BabylonViewer />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentView === 'trace' && (
|
{currentView === 'trace' && (
|
||||||
<TraceViewer traceId={selectedTraceId} />
|
<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,
|
HemisphericLight,
|
||||||
MeshBuilder,
|
MeshBuilder,
|
||||||
StandardMaterial,
|
StandardMaterial,
|
||||||
Color3
|
Color3,
|
||||||
|
Mesh,
|
||||||
|
DynamicTexture
|
||||||
} from 'babylonjs'
|
} 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 {
|
interface BabylonViewerProps {
|
||||||
width?: number
|
width?: number
|
||||||
height?: 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 canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const engineRef = useRef<Engine | null>(null)
|
const engineRef = useRef<Engine | null>(null)
|
||||||
const sceneRef = useRef<Scene | 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)
|
const scene = new Scene(engine)
|
||||||
sceneRef.current = scene
|
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)
|
camera.attachControl(canvas, true)
|
||||||
scene.activeCamera = camera
|
scene.activeCamera = camera
|
||||||
|
|
||||||
const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene)
|
const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene)
|
||||||
light.intensity = 0.7
|
light.intensity = 0.7
|
||||||
|
|
||||||
const sphere = MeshBuilder.CreateSphere('sphere', { diameter: 2 }, scene)
|
// Create ground plane for reference
|
||||||
sphere.position.y = 1
|
const ground = MeshBuilder.CreateGround('ground', { width: 50, height: 30 }, scene)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
const groundMaterial = new StandardMaterial('groundMaterial', 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
|
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(() => {
|
engine.runRenderLoop(() => {
|
||||||
scene.render()
|
scene.render()
|
||||||
})
|
})
|
||||||
@ -64,7 +282,7 @@ export default function BabylonViewer({ width = 800, height = 600 }: BabylonView
|
|||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [httpRequests])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { useDatabaseTraceData } from '../hooks/useDatabaseTraceData'
|
import { useDatabaseTraceData } from '../hooks/useDatabaseTraceData'
|
||||||
import type { TraceEvent } from '../../types/trace'
|
import type { TraceEvent } from '../../types/trace'
|
||||||
|
import ssim from 'ssim.js'
|
||||||
|
import BabylonViewer from '../BabylonViewer'
|
||||||
|
import BabylonTimelineViewer from '../BabylonTimelineViewer'
|
||||||
|
|
||||||
interface QueueAnalysis {
|
interface QueueAnalysis {
|
||||||
reason: 'connection_limit' | 'priority_queue' | 'resource_contention' | 'unknown'
|
reason: 'connection_limit' | 'priority_queue' | 'resource_contention' | 'unknown'
|
||||||
@ -48,6 +51,7 @@ interface HTTPRequest {
|
|||||||
end?: number
|
end?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
queueTime?: number
|
queueTime?: number
|
||||||
|
serverLatency?: number
|
||||||
networkDuration?: number
|
networkDuration?: number
|
||||||
dnsStart?: number
|
dnsStart?: number
|
||||||
dnsEnd?: number
|
dnsEnd?: number
|
||||||
@ -62,6 +66,7 @@ interface HTTPRequest {
|
|||||||
}
|
}
|
||||||
responseHeaders?: Array<{ name: string, value: string }>
|
responseHeaders?: Array<{ name: string, value: string }>
|
||||||
encodedDataLength?: number
|
encodedDataLength?: number
|
||||||
|
contentLength?: number
|
||||||
fromCache: boolean
|
fromCache: boolean
|
||||||
connectionReused: boolean
|
connectionReused: boolean
|
||||||
queueAnalysis?: QueueAnalysis
|
queueAnalysis?: QueueAnalysis
|
||||||
@ -70,6 +75,10 @@ interface HTTPRequest {
|
|||||||
|
|
||||||
const ITEMS_PER_PAGE = 25
|
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
|
// Global highlighting constants
|
||||||
const HIGHLIGHTING_CONFIG = {
|
const HIGHLIGHTING_CONFIG = {
|
||||||
// File size thresholds (in KB)
|
// File size thresholds (in KB)
|
||||||
@ -86,6 +95,13 @@ const HIGHLIGHTING_CONFIG = {
|
|||||||
FAST: 100 // Green highlighting for requests < 50ms
|
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 thresholds (in microseconds)
|
||||||
QUEUE_TIME: {
|
QUEUE_TIME: {
|
||||||
HIGH_THRESHOLD: 10000, // 10ms - highlight in red and show analysis
|
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
|
MEDIUM: { background: '#ffc107', color: 'black' }, // Yellow for 50-150ms
|
||||||
FAST: { background: '#28a745', color: 'white' }, // Green for < 50ms
|
FAST: { background: '#28a745', color: 'white' }, // Green for < 50ms
|
||||||
DEFAULT: { background: 'transparent', color: '#495057' } // Default
|
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 extractScreenshots = (traceEvents: TraceEvent[]): ScreenshotEvent[] => {
|
||||||
const screenshots: ScreenshotEvent[] = []
|
const screenshots: ScreenshotEvent[] = []
|
||||||
let index = 0
|
let index = 0
|
||||||
@ -201,23 +249,47 @@ const extractScreenshots = (traceEvents: TraceEvent[]): ScreenshotEvent[] => {
|
|||||||
return screenshots.sort((a, b) => a.timestamp - b.timestamp)
|
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 []
|
if (screenshots.length === 0) return []
|
||||||
|
|
||||||
const uniqueScreenshots: ScreenshotEvent[] = [screenshots[0]] // Always include first screenshot
|
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++) {
|
for (let i = 1; i < screenshots.length; i++) {
|
||||||
const current = screenshots[i]
|
const current = screenshots[i]
|
||||||
const lastUnique = uniqueScreenshots[uniqueScreenshots.length - 1]
|
const lastUnique = uniqueScreenshots[uniqueScreenshots.length - 1]
|
||||||
|
|
||||||
// Simple comparison - if the base64 data is different, it's a new screenshot
|
try {
|
||||||
// For better performance on large images, we could compare just a hash or portion
|
// 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) {
|
if (current.screenshot !== lastUnique.screenshot) {
|
||||||
uniqueScreenshots.push(current)
|
uniqueScreenshots.push(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('SSIM Analysis: Filtered from', screenshots.length, 'to', uniqueScreenshots.length, 'unique screenshots')
|
||||||
return uniqueScreenshots
|
return uniqueScreenshots
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,8 +305,57 @@ const analyzeCDN = (request: HTTPRequest): CDNAnalysis => {
|
|||||||
let confidence = 0
|
let confidence = 0
|
||||||
let detectionMethod = 'unknown'
|
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
|
// 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'
|
provider = 'cloudflare'
|
||||||
confidence = 0.95
|
confidence = 0.95
|
||||||
detectionMethod = 'headers (cf-ray/cf-cache-status)'
|
detectionMethod = 'headers (cf-ray/cf-cache-status)'
|
||||||
@ -251,21 +372,6 @@ const analyzeCDN = (request: HTTPRequest): CDNAnalysis => {
|
|||||||
edgeLocation = headerMap.get('cf-ray')?.split('-')[1]
|
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
|
// AWS CloudFront detection
|
||||||
else if (headerMap.has('x-amz-cf-id') || headerMap.has('x-amz-cf-pop') ||
|
else if (headerMap.has('x-amz-cf-id') || headerMap.has('x-amz-cf-pop') ||
|
||||||
hostname.includes('cloudfront.net')) {
|
hostname.includes('cloudfront.net')) {
|
||||||
@ -283,12 +389,22 @@ const analyzeCDN = (request: HTTPRequest): CDNAnalysis => {
|
|||||||
edgeLocation = headerMap.get('x-amz-cf-pop')
|
edgeLocation = headerMap.get('x-amz-cf-pop')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fastly detection
|
// Fastly detection (moved after Akamai to avoid false positives)
|
||||||
else if (headerMap.has('fastly-debug-digest') || headerMap.has('x-served-by') ||
|
else if (headerMap.has('fastly-debug-digest') ||
|
||||||
hostname.includes('fastly.com') || headerMap.get('via')?.includes('fastly')) {
|
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'
|
provider = 'fastly'
|
||||||
confidence = 0.85
|
confidence = 0.85
|
||||||
detectionMethod = 'headers (fastly-debug-digest/x-served-by/via)'
|
detectionMethod = 'headers (fastly-debug-digest/via/x-cache)'
|
||||||
isEdge = true
|
isEdge = true
|
||||||
|
|
||||||
const fastlyCache = headerMap.get('x-cache')
|
const fastlyCache = headerMap.get('x-cache')
|
||||||
@ -439,6 +555,9 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
const [priorityFilter, setPriorityFilter] = useState<string>('all')
|
const [priorityFilter, setPriorityFilter] = useState<string>('all')
|
||||||
const [showQueueAnalysis, setShowQueueAnalysis] = useState(false)
|
const [showQueueAnalysis, setShowQueueAnalysis] = useState(false)
|
||||||
const [showScreenshots, setShowScreenshots] = 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 [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const httpRequests = useMemo(() => {
|
const httpRequests = useMemo(() => {
|
||||||
@ -492,6 +611,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
request.mimeType = args.data.mimeType
|
request.mimeType = args.data.mimeType
|
||||||
request.protocol = args.data.protocol
|
request.protocol = args.data.protocol
|
||||||
request.responseHeaders = args.data.headers
|
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.fromCache = args.data.fromCache || false
|
||||||
request.connectionReused = args.data.connectionReused || false
|
request.connectionReused = args.data.connectionReused || false
|
||||||
request.timing.end = event.ts
|
request.timing.end = event.ts
|
||||||
@ -552,6 +682,16 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
request.timing.queueTime = requestTimeUs - request.events.sendRequest.ts
|
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
|
return request
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.timing.start - b.timing.start)
|
.sort((a, b) => a.timing.start - b.timing.start)
|
||||||
@ -581,19 +721,39 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
return sortedRequests
|
return sortedRequests
|
||||||
}, [traceData])
|
}, [traceData])
|
||||||
|
|
||||||
// Extract and process screenshots
|
// Extract and process screenshots with SSIM analysis
|
||||||
const screenshots = useMemo(() => {
|
const [screenshots, setScreenshots] = useState<ScreenshotEvent[]>([])
|
||||||
if (!traceData) return []
|
const [screenshotsLoading, setScreenshotsLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!traceData) {
|
||||||
|
setScreenshots([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const processScreenshots = async () => {
|
||||||
|
setScreenshotsLoading(true)
|
||||||
|
try {
|
||||||
const allScreenshots = extractScreenshots(traceData.traceEvents)
|
const allScreenshots = extractScreenshots(traceData.traceEvents)
|
||||||
console.log('Debug: Found screenshots:', allScreenshots.length)
|
console.log('Debug: Found screenshots:', allScreenshots.length)
|
||||||
console.log('Debug: Screenshot events sample:', allScreenshots.slice(0, 3))
|
console.log('Debug: Screenshot events sample:', allScreenshots.slice(0, 3))
|
||||||
|
|
||||||
const uniqueScreenshots = findUniqueScreenshots(allScreenshots)
|
const uniqueScreenshots = await findUniqueScreenshots(allScreenshots, ssimThreshold)
|
||||||
console.log('Debug: Unique screenshots:', uniqueScreenshots.length)
|
console.log('Debug: Unique screenshots after SSIM analysis:', uniqueScreenshots.length)
|
||||||
|
|
||||||
return uniqueScreenshots
|
setScreenshots(uniqueScreenshots)
|
||||||
}, [traceData])
|
} 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(() => {
|
const filteredRequests = useMemo(() => {
|
||||||
let requests = httpRequests
|
let requests = httpRequests
|
||||||
@ -782,6 +942,20 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
return HIGHLIGHTING_CONFIG.COLORS.DURATION.DEFAULT
|
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) => {
|
const getCDNIcon = (analysis?: CDNAnalysis) => {
|
||||||
if (!analysis) return ''
|
if (!analysis) return ''
|
||||||
|
|
||||||
@ -1057,8 +1231,33 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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 Toggle */}
|
||||||
{screenshots.length > 0 && (
|
{(screenshots.length > 0 || screenshotsLoading) && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
||||||
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Filmstrip:</label>
|
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Filmstrip:</label>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '14px' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '14px' }}>
|
||||||
@ -1067,9 +1266,36 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
checked={showScreenshots}
|
checked={showScreenshots}
|
||||||
onChange={(e) => setShowScreenshots(e.target.checked)}
|
onChange={(e) => setShowScreenshots(e.target.checked)}
|
||||||
style={{ cursor: 'pointer' }}
|
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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1129,6 +1355,172 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
</div>
|
</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 */}
|
{/* Requests Table */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white',
|
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: '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' }}>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' }}>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: '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' }}>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' }}>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' }}>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' }}>CDN</th>
|
||||||
<th style={{ padding: '8px', textAlign: 'center', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Cache</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>
|
</div>
|
||||||
</td>
|
</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' }}>
|
<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' }}>
|
<a href={request.url} target="_blank" rel="noopener noreferrer" style={{ color: '#007bff', textDecoration: 'none' }}>
|
||||||
{truncateUrl(request.url)}
|
{truncateUrl(request.url)}
|
||||||
@ -1323,6 +1728,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
}}>
|
}}>
|
||||||
{formatSize(request.encodedDataLength)}
|
{formatSize(request.encodedDataLength)}
|
||||||
</td>
|
</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={{
|
<td style={{
|
||||||
padding: '8px',
|
padding: '8px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
@ -1368,6 +1784,7 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
<div><strong>Method:</strong> {request.method}</div>
|
<div><strong>Method:</strong> {request.method}</div>
|
||||||
<div><strong>Priority:</strong> {request.priority}</div>
|
<div><strong>Priority:</strong> {request.priority}</div>
|
||||||
<div><strong>MIME Type:</strong> {request.mimeType || '-'}</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>From Cache:</strong> {request.fromCache ? 'Yes' : 'No'}</div>
|
||||||
<div><strong>Connection Reused:</strong> {request.connectionReused ? 'Yes' : 'No'}</div>
|
<div><strong>Connection Reused:</strong> {request.connectionReused ? 'Yes' : 'No'}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1386,6 +1803,7 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div><strong>Server Latency:</strong> {formatDuration(request.timing.serverLatency)}</div>
|
||||||
<div><strong>Total Duration:</strong> {formatDuration(request.timing.duration)}</div>
|
<div><strong>Total Duration:</strong> {formatDuration(request.timing.duration)}</div>
|
||||||
<div><strong>Network Duration:</strong> {formatDuration(request.timing.networkDuration)}</div>
|
<div><strong>Network Duration:</strong> {formatDuration(request.timing.networkDuration)}</div>
|
||||||
{request.timing.dnsStart !== -1 && request.timing.dnsEnd !== -1 && (
|
{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>Confidence:</strong> {(request.cdnAnalysis.confidence * 100).toFixed(0)}%</div>
|
||||||
<div><strong>Detection Method:</strong> {request.cdnAnalysis.detectionMethod}</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
Loading…
Reference in New Issue
Block a user