Enables users to download filtered HTTP request data as CSV files with comprehensive data including timing, sizes, CDN analysis, and queue analysis. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
571 lines
20 KiB
TypeScript
571 lines
20 KiB
TypeScript
import { useState, useMemo, useEffect, lazy, Suspense } from 'react'
|
||
import { useDatabaseTraceData } from '../../hooks/useDatabaseTraceData'
|
||
import { getUrlParams, updateUrlWithTraceId } from '../../App'
|
||
import RequestFilters from './RequestFilters'
|
||
import RequestsTable from './RequestsTable'
|
||
import ColumnSettings from './ColumnSettings'
|
||
import styles from './HTTPRequestViewer.module.css'
|
||
|
||
// Lazy load 3D viewers to reduce main bundle size
|
||
const BabylonViewer = lazy(() => import('../../BabylonViewer'))
|
||
const BabylonTimelineViewer = lazy(() => import('../../BabylonTimelineViewer'))
|
||
|
||
// Loading component for 3D viewers
|
||
const ThreeDViewerLoading = () => (
|
||
<div style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
height: '500px',
|
||
background: '#f8f9fa',
|
||
borderRadius: '8px'
|
||
}}>
|
||
<div style={{
|
||
width: '50px',
|
||
height: '50px',
|
||
border: '4px solid #e3f2fd',
|
||
borderTop: '4px solid #2196f3',
|
||
borderRadius: '50%',
|
||
animation: 'spin 1s linear infinite',
|
||
marginBottom: '20px'
|
||
}} />
|
||
<div style={{ color: '#6c757d', fontSize: '16px' }}>
|
||
Loading 3D Viewer...
|
||
</div>
|
||
<div style={{ color: '#9e9e9e', fontSize: '14px', marginTop: '8px' }}>
|
||
Initializing Babylon.js
|
||
</div>
|
||
<style>{`
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
)
|
||
|
||
// Imported utilities
|
||
import { ITEMS_PER_PAGE, SSIM_SIMILARITY_THRESHOLD } from './lib/httpRequestConstants'
|
||
import { extractScreenshots, findUniqueScreenshots } from './lib/screenshotUtils'
|
||
import { processHTTPRequests } from './lib/httpRequestProcessor'
|
||
import { analyzeCDN, analyzeQueueReason } from './lib/analysisUtils'
|
||
import { addRequestPostProcessing } from './lib/requestPostProcessor'
|
||
import { assignConnectionNumbers } from './lib/connectionUtils'
|
||
|
||
// Import types
|
||
import type { HTTPRequest, ScreenshotEvent } from './types/httpRequest'
|
||
import HTTPRequestLoading from "./HTTPRequestLoading.tsx";
|
||
import sortRequests from "./lib/sortRequests.ts";
|
||
import PaginationControls from "./PaginationControls.tsx";
|
||
import { exportRequestsToCSV, downloadCSV } from './lib/csvExport';
|
||
|
||
|
||
interface HTTPRequestViewerProps {
|
||
traceId: string | null
|
||
}
|
||
|
||
export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
||
const { traceData, loading, error } = useDatabaseTraceData(traceId)
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [resourceTypeFilter, setResourceTypeFilter] = useState<string>('all')
|
||
const [protocolFilter, setProtocolFilter] = useState<string>('all')
|
||
const [hostnameFilter, setHostnameFilter] = useState<string>('all')
|
||
const [priorityFilter, setPriorityFilter] = useState<string>('all')
|
||
const [showQueueAnalysis, setShowQueueAnalysis] = useState(false)
|
||
const [showScreenshots, setShowScreenshots] = useState(false)
|
||
// 3D viewer state - initialized from URL
|
||
const [show3DViewer, setShow3DViewer] = useState(false)
|
||
const [showTimelineViewer, setShowTimelineViewer] = useState(false)
|
||
|
||
// Column visibility state - default visible columns as requested
|
||
const [visibleColumns, setVisibleColumns] = useState(() => {
|
||
const saved = localStorage.getItem('httpRequestTableColumns')
|
||
if (saved) {
|
||
try {
|
||
return JSON.parse(saved)
|
||
} catch (e) {
|
||
console.warn('Failed to parse saved column settings, using defaults')
|
||
}
|
||
}
|
||
// Default visible columns
|
||
return {
|
||
expand: true,
|
||
method: false,
|
||
status: false,
|
||
type: false,
|
||
priority: false,
|
||
url: true,
|
||
connectionNumber: false,
|
||
requestNumber: false,
|
||
startTime: true,
|
||
queueTime: false,
|
||
dns: false,
|
||
connection: false,
|
||
serverLatency: false,
|
||
duration: false,
|
||
totalResponseTime: true,
|
||
dataRate: true,
|
||
size: false,
|
||
contentLength: true,
|
||
protocol: false,
|
||
cdn: false,
|
||
cache: false
|
||
}
|
||
})
|
||
const [columnSettingsOpen, setColumnSettingsOpen] = useState(false)
|
||
|
||
// Initialize 3D view state from URL on component mount and handle URL changes
|
||
useEffect(() => {
|
||
const updateFrom3DUrl = () => {
|
||
const { threeDView } = getUrlParams()
|
||
setShow3DViewer(threeDView === 'network')
|
||
setShowTimelineViewer(threeDView === 'timeline')
|
||
}
|
||
|
||
// Set initial state
|
||
updateFrom3DUrl()
|
||
|
||
// Listen for URL changes (back/forward navigation)
|
||
const handlePopState = () => updateFrom3DUrl()
|
||
window.addEventListener('popstate', handlePopState)
|
||
|
||
return () => window.removeEventListener('popstate', handlePopState)
|
||
}, [])
|
||
|
||
// Handle 3D view changes and update URL
|
||
const handle3DViewerToggle = (enabled: boolean) => {
|
||
const { traceId, view } = getUrlParams()
|
||
setShow3DViewer(enabled)
|
||
setShowTimelineViewer(false) // Ensure only one 3D view is active
|
||
updateUrlWithTraceId(traceId, view, enabled ? 'network' : null)
|
||
}
|
||
|
||
const handleTimelineViewerToggle = (enabled: boolean) => {
|
||
const { traceId, view } = getUrlParams()
|
||
setShowTimelineViewer(enabled)
|
||
setShow3DViewer(false) // Ensure only one 3D view is active
|
||
updateUrlWithTraceId(traceId, view, enabled ? 'timeline' : null)
|
||
}
|
||
const [ssimThreshold, setSsimThreshold] = useState(SSIM_SIMILARITY_THRESHOLD)
|
||
const [pendingSSIMThreshold, setPendingSSIMThreshold] = useState(SSIM_SIMILARITY_THRESHOLD)
|
||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||
|
||
const handleSSIMRecalculate = () => {
|
||
setSsimThreshold(pendingSSIMThreshold)
|
||
}
|
||
|
||
// Column visibility handlers
|
||
const handleColumnToggle = (column: string) => {
|
||
const newVisibleColumns = {
|
||
...visibleColumns,
|
||
[column]: !visibleColumns[column]
|
||
}
|
||
setVisibleColumns(newVisibleColumns)
|
||
localStorage.setItem('httpRequestTableColumns', JSON.stringify(newVisibleColumns))
|
||
}
|
||
|
||
const handleShowAllColumns = () => {
|
||
const allVisible = Object.keys(visibleColumns).reduce((acc, key) => {
|
||
acc[key] = true
|
||
return acc
|
||
}, {} as Record<string, boolean>)
|
||
setVisibleColumns(allVisible)
|
||
localStorage.setItem('httpRequestTableColumns', JSON.stringify(allVisible))
|
||
}
|
||
|
||
const handleHideAllColumns = () => {
|
||
const allHidden = Object.keys(visibleColumns).reduce((acc, key) => {
|
||
acc[key] = key === 'expand' // Always keep expand column visible
|
||
return acc
|
||
}, {} as Record<string, boolean>)
|
||
setVisibleColumns(allHidden)
|
||
localStorage.setItem('httpRequestTableColumns', JSON.stringify(allHidden))
|
||
}
|
||
|
||
const handleResetDefaults = () => {
|
||
const defaultColumns = {
|
||
expand: true,
|
||
method: false,
|
||
status: false,
|
||
type: false,
|
||
priority: false,
|
||
url: true,
|
||
connectionNumber: false,
|
||
requestNumber: false,
|
||
startTime: true,
|
||
queueTime: false,
|
||
dns: false,
|
||
connection: false,
|
||
serverLatency: false,
|
||
duration: false,
|
||
totalResponseTime: true,
|
||
dataRate: true,
|
||
size: false,
|
||
contentLength: true,
|
||
protocol: false,
|
||
cdn: false,
|
||
cache: false
|
||
}
|
||
setVisibleColumns(defaultColumns)
|
||
localStorage.setItem('httpRequestTableColumns', JSON.stringify(defaultColumns))
|
||
}
|
||
|
||
const httpRequests = useMemo(() => {
|
||
if (!traceData) return []
|
||
const httpRequests = processHTTPRequests(traceData.traceEvents)
|
||
const sortedRequests = sortRequests(httpRequests)
|
||
const processedRequests = addRequestPostProcessing(sortedRequests, analyzeQueueReason, analyzeCDN)
|
||
const requestsWithConnections = assignConnectionNumbers(processedRequests)
|
||
return requestsWithConnections
|
||
}, [traceData])
|
||
|
||
// Extract and process screenshots with SSIM analysis
|
||
const [screenshots, setScreenshots] = useState<ScreenshotEvent[]>([])
|
||
const [screenshotsLoading, setScreenshotsLoading] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (!traceData) {
|
||
setScreenshots([])
|
||
return
|
||
}
|
||
|
||
const processScreenshots = async () => {
|
||
setScreenshotsLoading(true)
|
||
try {
|
||
const allScreenshots = extractScreenshots(traceData.traceEvents)
|
||
console.log('Debug: Found screenshots:', allScreenshots.length)
|
||
console.log('Debug: Screenshot events sample:', allScreenshots.slice(0, 3))
|
||
|
||
const uniqueScreenshots = await findUniqueScreenshots(allScreenshots, ssimThreshold)
|
||
console.log('Debug: Unique screenshots after SSIM analysis:', uniqueScreenshots.length)
|
||
|
||
setScreenshots(uniqueScreenshots)
|
||
} catch (error) {
|
||
console.error('Error processing screenshots with SSIM:', error)
|
||
// Fallback to extracting all screenshots without SSIM filtering
|
||
const allScreenshots = extractScreenshots(traceData.traceEvents)
|
||
setScreenshots(allScreenshots)
|
||
} finally {
|
||
setScreenshotsLoading(false)
|
||
}
|
||
}
|
||
|
||
processScreenshots()
|
||
}, [traceData, ssimThreshold])
|
||
|
||
const filteredRequests = useMemo(() => {
|
||
let requests = httpRequests
|
||
|
||
// Filter by resource type
|
||
if (resourceTypeFilter !== 'all') {
|
||
requests = requests.filter(req => req.resourceType === resourceTypeFilter)
|
||
}
|
||
|
||
// Filter by protocol
|
||
if (protocolFilter !== 'all') {
|
||
requests = requests.filter(req => req.protocol === protocolFilter)
|
||
}
|
||
|
||
// Filter by hostname
|
||
if (hostnameFilter !== 'all') {
|
||
requests = requests.filter(req => req.hostname === hostnameFilter)
|
||
}
|
||
|
||
// Filter by priority
|
||
if (priorityFilter !== 'all') {
|
||
requests = requests.filter(req => req.priority === priorityFilter)
|
||
}
|
||
|
||
// Filter by search term
|
||
if (searchTerm) {
|
||
const term = searchTerm.toLowerCase()
|
||
requests = requests.filter(req =>
|
||
req.url.toLowerCase().includes(term) ||
|
||
req.method.toLowerCase().includes(term) ||
|
||
req.resourceType.toLowerCase().includes(term) ||
|
||
req.hostname.toLowerCase().includes(term) ||
|
||
req.statusCode?.toString().includes(term)
|
||
)
|
||
}
|
||
|
||
return requests
|
||
}, [httpRequests, resourceTypeFilter, protocolFilter, hostnameFilter, priorityFilter, searchTerm])
|
||
|
||
const paginatedRequests = useMemo(() => {
|
||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
|
||
return filteredRequests.slice(startIndex, startIndex + ITEMS_PER_PAGE)
|
||
}, [filteredRequests, currentPage])
|
||
|
||
// Create timeline entries that include both HTTP requests and screenshot changes
|
||
const timelineEntries = useMemo(() => {
|
||
const entries: Array<{
|
||
type: 'request' | 'screenshot',
|
||
timestamp: number,
|
||
data: HTTPRequest | ScreenshotEvent
|
||
}> = []
|
||
|
||
// Add HTTP requests
|
||
filteredRequests.forEach(request => {
|
||
entries.push({
|
||
type: 'request',
|
||
timestamp: request.timing.start,
|
||
data: request
|
||
})
|
||
})
|
||
|
||
// Add screenshots if enabled
|
||
if (showScreenshots && screenshots.length > 0) {
|
||
screenshots.forEach(screenshot => {
|
||
entries.push({
|
||
type: 'screenshot',
|
||
timestamp: screenshot.timestamp,
|
||
data: screenshot
|
||
})
|
||
})
|
||
}
|
||
|
||
// Sort by timestamp
|
||
return entries.sort((a, b) => a.timestamp - b.timestamp)
|
||
}, [filteredRequests, screenshots, showScreenshots])
|
||
|
||
const paginatedTimelineEntries = useMemo(() => {
|
||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
|
||
return timelineEntries.slice(startIndex, startIndex + ITEMS_PER_PAGE)
|
||
}, [timelineEntries, currentPage])
|
||
|
||
const resourceTypes = useMemo(() => {
|
||
const types = new Set(httpRequests.map(req => req.resourceType))
|
||
return Array.from(types).sort()
|
||
}, [httpRequests])
|
||
|
||
const protocols = useMemo(() => {
|
||
const protos = new Set(httpRequests.map(req => req.protocol).filter((p): p is string => Boolean(p)))
|
||
return Array.from(protos).sort()
|
||
}, [httpRequests])
|
||
|
||
const hostnames = useMemo(() => {
|
||
const hosts = new Set(httpRequests.map(req => req.hostname).filter(h => h !== 'unknown'))
|
||
return Array.from(hosts).sort()
|
||
}, [httpRequests])
|
||
|
||
const priorities = useMemo(() => {
|
||
const prios = new Set(httpRequests.map(req => req.priority).filter(Boolean))
|
||
return Array.from(prios).sort((a, b) => {
|
||
// Sort priorities by importance: VeryHigh, High, Medium, Low, VeryLow
|
||
const order = ['VeryHigh', 'High', 'Medium', 'Low', 'VeryLow']
|
||
return order.indexOf(a) - order.indexOf(b)
|
||
})
|
||
}, [httpRequests])
|
||
|
||
const totalPages = Math.ceil((showScreenshots ? timelineEntries.length : filteredRequests.length) / ITEMS_PER_PAGE)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
const toggleRowExpansion = (requestId: string) => {
|
||
const newExpanded = new Set(expandedRows)
|
||
if (newExpanded.has(requestId)) {
|
||
newExpanded.delete(requestId)
|
||
} else {
|
||
newExpanded.add(requestId)
|
||
}
|
||
setExpandedRows(newExpanded)
|
||
}
|
||
|
||
const handleDownloadCSV = () => {
|
||
const csvContent = exportRequestsToCSV(filteredRequests, {
|
||
includeHeaders: true
|
||
})
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||
const filename = `http-requests-${timestamp}.csv`
|
||
downloadCSV(csvContent, filename)
|
||
}
|
||
|
||
|
||
if (loading) {
|
||
return (
|
||
<HTTPRequestLoading/>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className={styles.errorContainer}>
|
||
<div className={styles.errorMessage}>
|
||
<h3>Error Loading Trace Data</h3>
|
||
<p>{error}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
<h2>HTTP Requests & Responses</h2>
|
||
{/* Controls */}
|
||
<RequestFilters
|
||
httpRequests={httpRequests}
|
||
screenshots={screenshots}
|
||
screenshotsLoading={screenshotsLoading}
|
||
resourceTypes={resourceTypes}
|
||
protocols={protocols}
|
||
hostnames={hostnames}
|
||
priorities={priorities}
|
||
filteredRequests={filteredRequests}
|
||
timelineEntries={timelineEntries}
|
||
resourceTypeFilter={resourceTypeFilter}
|
||
protocolFilter={protocolFilter}
|
||
hostnameFilter={hostnameFilter}
|
||
priorityFilter={priorityFilter}
|
||
searchTerm={searchTerm}
|
||
showQueueAnalysis={showQueueAnalysis}
|
||
showScreenshots={showScreenshots}
|
||
show3DViewer={show3DViewer}
|
||
showTimelineViewer={showTimelineViewer}
|
||
pendingSSIMThreshold={pendingSSIMThreshold}
|
||
ssimThreshold={ssimThreshold}
|
||
setResourceTypeFilter={setResourceTypeFilter}
|
||
setProtocolFilter={setProtocolFilter}
|
||
setHostnameFilter={setHostnameFilter}
|
||
setPriorityFilter={setPriorityFilter}
|
||
setSearchTerm={setSearchTerm}
|
||
setCurrentPage={setCurrentPage}
|
||
setShowQueueAnalysis={setShowQueueAnalysis}
|
||
setShowScreenshots={setShowScreenshots}
|
||
setShow3DViewer={handle3DViewerToggle}
|
||
setShowTimelineViewer={handleTimelineViewerToggle}
|
||
setPendingSSIMThreshold={setPendingSSIMThreshold}
|
||
handleSSIMRecalculate={handleSSIMRecalculate}
|
||
/>
|
||
|
||
{/* Export Controls */}
|
||
<div className={styles.exportControls}>
|
||
<button
|
||
onClick={handleDownloadCSV}
|
||
className={styles.downloadButton}
|
||
disabled={filteredRequests.length === 0}
|
||
>
|
||
📥 Download CSV ({filteredRequests.length} requests)
|
||
</button>
|
||
</div>
|
||
|
||
<PaginationControls currentPage={currentPage} setCurrentPage={setCurrentPage} totalPages={totalPages} />
|
||
|
||
{/* 3D Network Visualization Modal */}
|
||
{show3DViewer && (
|
||
<div className={styles.modalOverlay}>
|
||
<div className={styles.modalContainer}>
|
||
<div className={styles.modalHeader}>
|
||
<h3 className={styles.modalTitle}>
|
||
3D Network Visualization ({filteredRequests.length} requests)
|
||
</h3>
|
||
<button
|
||
onClick={() => handle3DViewerToggle(false)}
|
||
className={styles.modalCloseButton}
|
||
>
|
||
✕ Close
|
||
</button>
|
||
</div>
|
||
<div className={styles.modalContent}>
|
||
<Suspense fallback={<ThreeDViewerLoading />}>
|
||
<BabylonViewer httpRequests={filteredRequests} width={window.innerWidth * 0.9} height={window.innerHeight * 0.9 - 100} />
|
||
</Suspense>
|
||
</div>
|
||
<div className={styles.modalLegend}>
|
||
<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 className={styles.modalOverlay}>
|
||
<div className={styles.modalContainer}>
|
||
<div className={styles.modalHeader}>
|
||
<h3 className={styles.modalTitle}>
|
||
3D Timeline Visualization ({filteredRequests.length} requests)
|
||
</h3>
|
||
<button
|
||
onClick={() => handleTimelineViewerToggle(false)}
|
||
className={styles.modalCloseButton}
|
||
>
|
||
✕ Close
|
||
</button>
|
||
</div>
|
||
<div className={styles.modalContent}>
|
||
<Suspense fallback={<ThreeDViewerLoading />}>
|
||
<BabylonTimelineViewer httpRequests={filteredRequests} screenshots={screenshots} width={window.innerWidth * 0.9} height={window.innerHeight * 0.9 - 100} />
|
||
</Suspense>
|
||
</div>
|
||
<div className={styles.modalLegend}>
|
||
<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>
|
||
)}
|
||
|
||
{/* Column Settings */}
|
||
<ColumnSettings
|
||
visibleColumns={visibleColumns}
|
||
onColumnToggle={handleColumnToggle}
|
||
isOpen={columnSettingsOpen}
|
||
onToggle={() => setColumnSettingsOpen(!columnSettingsOpen)}
|
||
onShowAll={handleShowAllColumns}
|
||
onHideAll={handleHideAllColumns}
|
||
onResetDefaults={handleResetDefaults}
|
||
/>
|
||
|
||
{/* Requests Table */}
|
||
<RequestsTable
|
||
httpRequests={httpRequests}
|
||
showScreenshots={showScreenshots}
|
||
paginatedTimelineEntries={paginatedTimelineEntries}
|
||
paginatedRequests={paginatedRequests}
|
||
showQueueAnalysis={showQueueAnalysis}
|
||
expandedRows={expandedRows}
|
||
onToggleRowExpansion={toggleRowExpansion}
|
||
visibleColumns={visibleColumns}
|
||
/>
|
||
</div>
|
||
)
|
||
} |