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 HTTPRequestLoading from "./HTTPRequestLoading.tsx";
|
||||||
import sortRequests from "./lib/sortRequests.ts";
|
import sortRequests from "./lib/sortRequests.ts";
|
||||||
import PaginationControls from "./PaginationControls.tsx";
|
import PaginationControls from "./PaginationControls.tsx";
|
||||||
|
import { exportRequestsToCSV, downloadCSV } from './lib/csvExport';
|
||||||
|
|
||||||
|
|
||||||
interface HTTPRequestViewerProps {
|
interface HTTPRequestViewerProps {
|
||||||
@ -382,6 +383,15 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
setExpandedRows(newExpanded)
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -439,6 +449,17 @@ export default function HTTPRequestViewer({ traceId }: HTTPRequestViewerProps) {
|
|||||||
handleSSIMRecalculate={handleSSIMRecalculate}
|
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} />
|
<PaginationControls currentPage={currentPage} setCurrentPage={setCurrentPage} totalPages={totalPages} />
|
||||||
|
|
||||||
{/* 3D Network Visualization Modal */}
|
{/* 3D Network Visualization Modal */}
|
||||||
|
@ -214,7 +214,6 @@ const RequestsTable: React.FC<RequestsTableProps> = ({
|
|||||||
<RequestRowSummary
|
<RequestRowSummary
|
||||||
key={request.requestId}
|
key={request.requestId}
|
||||||
request={request}
|
request={request}
|
||||||
allRequests={httpRequests}
|
|
||||||
showQueueAnalysis={showQueueAnalysis}
|
showQueueAnalysis={showQueueAnalysis}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
onToggleRowExpansion={onToggleRowExpansion}
|
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