perfViz/src/components/httprequestviewer/HTTPRequestViewer.tsx
Michael Mainguy a6a7bbb65b Add CSV export functionality to HTTP requests table
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>
2025-08-19 06:43:54 -05:00

571 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}