From a6a7bbb65b36cc56e515d10788232ba60dfbd542 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Tue, 19 Aug 2025 06:43:54 -0500 Subject: [PATCH] Add CSV export functionality to HTTP requests table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../HTTPRequestViewer.module.css | 129 +++++++++++++++ .../httprequestviewer/HTTPRequestViewer.tsx | 21 +++ .../httprequestviewer/RequestsTable.tsx | 1 - .../httprequestviewer/lib/csvExport.ts | 150 ++++++++++++++++++ 4 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 src/components/httprequestviewer/lib/csvExport.ts diff --git a/src/components/httprequestviewer/HTTPRequestViewer.module.css b/src/components/httprequestviewer/HTTPRequestViewer.module.css index e69de29..8fee649 100644 --- a/src/components/httprequestviewer/HTTPRequestViewer.module.css +++ b/src/components/httprequestviewer/HTTPRequestViewer.module.css @@ -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; +} \ No newline at end of file diff --git a/src/components/httprequestviewer/HTTPRequestViewer.tsx b/src/components/httprequestviewer/HTTPRequestViewer.tsx index 253a135..bfa2555 100644 --- a/src/components/httprequestviewer/HTTPRequestViewer.tsx +++ b/src/components/httprequestviewer/HTTPRequestViewer.tsx @@ -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 */} +
+ +
+ {/* 3D Network Visualization Modal */} diff --git a/src/components/httprequestviewer/RequestsTable.tsx b/src/components/httprequestviewer/RequestsTable.tsx index a16b69e..c477489 100644 --- a/src/components/httprequestviewer/RequestsTable.tsx +++ b/src/components/httprequestviewer/RequestsTable.tsx @@ -214,7 +214,6 @@ const RequestsTable: React.FC = ({ { + 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() + + 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() +} \ No newline at end of file