From 8a791a1186f3852c0f0a6f0feb58ff1220dc56d1 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Thu, 7 Aug 2025 09:21:45 -0500 Subject: [PATCH] Implement comprehensive 3D timeline visualization with enhanced features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package-lock.json | 9 +- package.json | 3 +- src/App.tsx | 23 +- src/BabylonTimelineViewer.tsx | 458 +++++++++++++++++++++ src/BabylonViewer.tsx | 246 +++++++++++- src/components/HTTPRequestViewer.tsx | 570 +++++++++++++++++++++++++-- 6 files changed, 1229 insertions(+), 80 deletions(-) create mode 100644 src/BabylonTimelineViewer.tsx diff --git a/package-lock.json b/package-lock.json index 4afb441..5645da5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3542a4b..45fe6e7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 7cb3e14..e942589 100644 --- a/src/App.tsx +++ b/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() { - {currentView === '3d' && ( -
- -
- )} - {currentView === 'trace' && ( )} diff --git a/src/BabylonTimelineViewer.tsx b/src/BabylonTimelineViewer.tsx new file mode 100644 index 0000000..0092cdd --- /dev/null +++ b/src/BabylonTimelineViewer.tsx @@ -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(null) + const engineRef = useRef(null) + const sceneRef = useRef(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() + 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 ( + + ) +} \ No newline at end of file diff --git a/src/BabylonViewer.tsx b/src/BabylonViewer.tsx index fb90938..7c35d62 100644 --- a/src/BabylonViewer.tsx +++ b/src/BabylonViewer.tsx @@ -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(null) const engineRef = useRef(null) const sceneRef = useRef(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() + 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 ( 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 => { + 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 => { 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('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>(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([]) + 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) { + {/* 3D Viewer Toggle */} + {httpRequests.length > 0 && ( +
+ + + +
+ )} + {/* Screenshots Toggle */} - {screenshots.length > 0 && ( + {(screenshots.length > 0 || screenshotsLoading) && (
+ {screenshots.length > 0 && !screenshotsLoading && ( +
+ +
+ Lower values = more screenshots kept (more sensitive to changes) +
+
+ )}
)} @@ -1129,6 +1355,172 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) { )} + {/* 3D Network Visualization Modal */} + {show3DViewer && ( +
+
+
+

+ 3D Network Visualization ({filteredRequests.length} requests) +

+ +
+
+ +
+
+
Legend:
+
⮛→⮜ Gray gradient: 2xx Success (darker=lower, lighter=higher)
+
ðŸŸĄ Yellow: 3xx Redirects
+
🟠 Orange: 4xx Client Errors
+
ðŸ”ī Red: 5xx Server Errors
+
Layout:
+
ðŸ”ĩ Central sphere: Origin
+
🏷ïļ Hostname labels: At 12m radius
+
ðŸ“Ķ Request boxes: Start → end timeline
+
📏 Front face: Request start time
+
📐 Height: 0.1m-5m (content-length)
+
📊 Depth: Request duration
+
📚 Overlapping requests stack vertically
+
🔗 Connection lines to center
+
👁ïļ Labels always face camera
+
+
+
+ )} + + {/* 3D Timeline Visualization Modal */} + {showTimelineViewer && ( +
+
+
+

+ 3D Timeline Visualization ({filteredRequests.length} requests) +

+ +
+
+ +
+
+
Legend:
+
⮛→⮜ Gray gradient: 2xx Success (darker=lower, lighter=higher)
+
ðŸŸĄ Yellow: 3xx Redirects
+
🟠 Orange: 4xx Client Errors
+
ðŸ”ī Red: 5xx Server Errors
+
Timeline Layout:
+
ðŸ”ĩ Central sphere: Timeline origin
+
🏷ïļ Hostname labels: At 12m radius
+
ðŸ“Ķ Request boxes: Chronological timeline
+
📏 Distance from center: Start time
+
📐 Height: 0.1m-5m (content-length)
+
📊 Depth: Request duration
+
📚 Overlapping requests stack vertically
+
🔗 Connection lines to center
+
👁ïļ Labels face origin (180° rotated)
+
+
+
+ )} + {/* Requests Table */}
Priority Start Time Queue Time + Server Latency URL Duration Size + Content-Length Protocol CDN Cache @@ -1296,6 +1690,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) { )}
+ + {formatDuration(request.timing.serverLatency)} + {truncateUrl(request.url)} @@ -1323,6 +1728,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) { }}> {formatSize(request.encodedDataLength)} + + {request.contentLength ? formatSize(request.contentLength) : '-'} + Method: {request.method}
Priority: {request.priority}
MIME Type: {request.mimeType || '-'}
+
Content-Length: {request.contentLength ? formatSize(request.contentLength) : '-'}
From Cache: {request.fromCache ? 'Yes' : 'No'}
Connection Reused: {request.connectionReused ? 'Yes' : 'No'}
@@ -1386,6 +1803,7 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) { )} +
Server Latency: {formatDuration(request.timing.serverLatency)}
Total Duration: {formatDuration(request.timing.duration)}
Network Duration: {formatDuration(request.timing.networkDuration)}
{request.timing.dnsStart !== -1 && request.timing.dnsEnd !== -1 && ( @@ -1446,6 +1864,74 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) { )}
Confidence: {(request.cdnAnalysis.confidence * 100).toFixed(0)}%
Detection Method: {request.cdnAnalysis.detectionMethod}
+ + {/* Debug info for canadiantire.ca requests */} + {request.hostname.includes('canadiantire.ca') && ( +
+
+ Debug - CDN Detection Analysis: +
+
+ Current Detection: {request.cdnAnalysis?.provider} + (confidence: {((request.cdnAnalysis?.confidence || 0) * 100).toFixed(0)}%) +
+
+ All CDN-Related Headers: +
+ {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 ( +
+ + {header.name}: + {header.value} + {isAkamaiIndicator && ( + + ← AKAMAI + + )} +
+ ) + } + return null + })} +
+ )} )}