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>
This commit is contained in:
parent
488d9a2650
commit
a6a7bbb65b
@ -0,0 +1,129 @@
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorMessage h3 {
|
||||
color: #c33;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.exportControls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 15px 0;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.downloadButton:hover:not(:disabled) {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.downloadButton:disabled {
|
||||
background: #cccccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modalContainer {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 95vw;
|
||||
height: 95vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.modalCloseButton {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modalCloseButton:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modalLegend {
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding: 15px 20px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modalLegend div {
|
||||
margin-bottom: 4px;
|
||||
}
|
@ -58,6 +58,7 @@ 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 {
|
||||
@ -382,6 +383,15 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
||||
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 (
|
||||
@ -439,6 +449,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
||||
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 */}
|
||||
|
@ -214,7 +214,6 @@ const RequestsTable: React.FC<RequestsTableProps> = ({
|
||||
<RequestRowSummary
|
||||
key={request.requestId}
|
||||
request={request}
|
||||
allRequests={httpRequests}
|
||||
showQueueAnalysis={showQueueAnalysis}
|
||||
isExpanded={isExpanded}
|
||||
onToggleRowExpansion={onToggleRowExpansion}
|
||||
|
150
src/components/httprequestviewer/lib/csvExport.ts
Normal file
150
src/components/httprequestviewer/lib/csvExport.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import type { HTTPRequest } from '../types/httpRequest'
|
||||
|
||||
export interface CSVExportOptions {
|
||||
includeHeaders?: boolean
|
||||
selectedColumns?: string[]
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMNS = [
|
||||
'method',
|
||||
'url',
|
||||
'hostname',
|
||||
'statusCode',
|
||||
'resourceType',
|
||||
'priority',
|
||||
'protocol',
|
||||
'connectionNumber',
|
||||
'requestNumber',
|
||||
'startTime',
|
||||
'queueTime',
|
||||
'dnsTime',
|
||||
'connectionTime',
|
||||
'serverLatency',
|
||||
'duration',
|
||||
'totalResponseTime',
|
||||
'dataRate',
|
||||
'size',
|
||||
'contentLength',
|
||||
'fromCache',
|
||||
'connectionReused',
|
||||
'cdnAnalysis.provider',
|
||||
'queueAnalysis.reason'
|
||||
]
|
||||
|
||||
// Format a value for CSV output
|
||||
const formatCSVValue = (value: any): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value.toString()
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const stringValue = value.toString()
|
||||
|
||||
// Escape quotes and wrap in quotes if contains comma, quote, or newline
|
||||
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
return stringValue
|
||||
}
|
||||
|
||||
// Get nested property value from object
|
||||
const getNestedValue = (obj: any, path: string): any => {
|
||||
return path.split('.').reduce((current, key) => {
|
||||
return current && current[key] !== undefined ? current[key] : null
|
||||
}, obj)
|
||||
}
|
||||
|
||||
// Convert HTTP requests to CSV format
|
||||
export const exportRequestsToCSV = (
|
||||
requests: HTTPRequest[],
|
||||
options: CSVExportOptions = {}
|
||||
): string => {
|
||||
const {
|
||||
includeHeaders = true,
|
||||
selectedColumns = DEFAULT_COLUMNS
|
||||
} = options
|
||||
|
||||
if (requests.length === 0) {
|
||||
return includeHeaders ? selectedColumns.join(',') + '\n' : ''
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
// Add headers if requested
|
||||
if (includeHeaders) {
|
||||
const headers = selectedColumns.map(col => {
|
||||
// Clean up column names for headers
|
||||
return col
|
||||
.replace(/([A-Z])/g, ' $1') // Add space before capitals
|
||||
.replace(/^\w/, c => c.toUpperCase()) // Capitalize first letter
|
||||
.replace(/\./g, ' ') // Replace dots with spaces
|
||||
.trim()
|
||||
})
|
||||
lines.push(headers.map(formatCSVValue).join(','))
|
||||
}
|
||||
|
||||
// Add data rows
|
||||
for (const request of requests) {
|
||||
const values = selectedColumns.map(column => {
|
||||
const value = getNestedValue(request, column)
|
||||
return formatCSVValue(value)
|
||||
})
|
||||
lines.push(values.join(','))
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// Download CSV file
|
||||
export const downloadCSV = (csvContent: string, filename: string = 'http-requests.csv'): void => {
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
|
||||
if (link.download !== undefined) {
|
||||
// Use HTML5 download attribute
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', filename)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all available columns from HTTP requests
|
||||
export const getAvailableColumns = (requests: HTTPRequest[]): string[] => {
|
||||
if (requests.length === 0) {
|
||||
return DEFAULT_COLUMNS
|
||||
}
|
||||
|
||||
const columns = new Set<string>()
|
||||
|
||||
const extractColumns = (obj: any, prefix = ''): void => {
|
||||
Object.keys(obj).forEach(key => {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key
|
||||
const value = obj[key]
|
||||
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
// Recursively extract nested properties
|
||||
extractColumns(value, fullKey)
|
||||
} else {
|
||||
columns.add(fullKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Extract columns from first request
|
||||
extractColumns(requests[0])
|
||||
|
||||
return Array.from(columns).sort()
|
||||
}
|
Loading…
Reference in New Issue
Block a user