From ec91cfbafd971dfb27268fa0c2ba3491e8d1d06d Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Fri, 8 Aug 2025 13:21:17 -0500 Subject: [PATCH] Refactored code to use css and be more modular. --- CLAUDE.md | 10 +- analysis.css | 578 +++++ optimized.css | 707 ++++++ src/App.tsx | 27 +- src/BabylonTimelineViewer.tsx | 84 +- src/components/HTTPRequestViewer.tsx | 2004 ----------------- src/components/RequestDebugger.tsx | 811 +++++++ .../HTTPRequestLoading.module.css | 26 + .../httprequestviewer/HTTPRequestLoading.tsx | 13 + .../HTTPRequestViewer.module.css | 705 ++++++ .../httprequestviewer/HTTPRequestViewer.tsx | 388 ++++ .../RequestFilters.module.css | 139 ++ .../httprequestviewer/RequestFilters.tsx | 290 +++ .../httprequestviewer/RequestRowDetails.tsx | 224 ++ .../httprequestviewer/RequestRowSummary.tsx | 139 ++ .../httprequestviewer/RequestsTable.tsx | 106 + .../httprequestviewer/ScreenshotRow.tsx | 55 + .../lib/addTimingToRequest.ts | 34 + .../httprequestviewer/lib/analysisUtils.ts | 250 ++ .../httprequestviewer/lib/colorUtils.ts | 130 ++ .../httprequestviewer/lib/filterUtils.ts | 87 + .../httprequestviewer/lib/formatUtils.ts | 13 + .../lib/httpRequestConstants.ts | 96 + .../lib/httpRequestProcessor.ts | 295 +++ .../lib/requestPostProcessor.ts | 30 + .../httprequestviewer/lib/screenshotUtils.ts | 125 + .../httprequestviewer/lib/sortRequests.ts | 87 + .../httprequestviewer/lib/urlUtils.ts | 16 + .../httprequestviewer/types/httpRequest.ts | 70 + src/types/trace.ts | 20 + 30 files changed, 5538 insertions(+), 2021 deletions(-) create mode 100644 analysis.css create mode 100644 optimized.css delete mode 100644 src/components/HTTPRequestViewer.tsx create mode 100644 src/components/RequestDebugger.tsx create mode 100644 src/components/httprequestviewer/HTTPRequestLoading.module.css create mode 100644 src/components/httprequestviewer/HTTPRequestLoading.tsx create mode 100644 src/components/httprequestviewer/HTTPRequestViewer.module.css create mode 100644 src/components/httprequestviewer/HTTPRequestViewer.tsx create mode 100644 src/components/httprequestviewer/RequestFilters.module.css create mode 100644 src/components/httprequestviewer/RequestFilters.tsx create mode 100644 src/components/httprequestviewer/RequestRowDetails.tsx create mode 100644 src/components/httprequestviewer/RequestRowSummary.tsx create mode 100644 src/components/httprequestviewer/RequestsTable.tsx create mode 100644 src/components/httprequestviewer/ScreenshotRow.tsx create mode 100644 src/components/httprequestviewer/lib/addTimingToRequest.ts create mode 100644 src/components/httprequestviewer/lib/analysisUtils.ts create mode 100644 src/components/httprequestviewer/lib/colorUtils.ts create mode 100644 src/components/httprequestviewer/lib/filterUtils.ts create mode 100644 src/components/httprequestviewer/lib/formatUtils.ts create mode 100644 src/components/httprequestviewer/lib/httpRequestConstants.ts create mode 100644 src/components/httprequestviewer/lib/httpRequestProcessor.ts create mode 100644 src/components/httprequestviewer/lib/requestPostProcessor.ts create mode 100644 src/components/httprequestviewer/lib/screenshotUtils.ts create mode 100644 src/components/httprequestviewer/lib/sortRequests.ts create mode 100644 src/components/httprequestviewer/lib/urlUtils.ts create mode 100644 src/components/httprequestviewer/types/httpRequest.ts create mode 100644 src/types/trace.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9ff7d50..834528b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,13 @@ This is a React + TypeScript + Vite project called "perfviz" - a modern web appl - **HMR (Hot Module Replacement)** is enabled through Vite's React plugin - **Strict mode** is enabled in development for better debugging - **Browser targets** are ES2022+ for modern JavaScript features +- **Lines of code per function**: Functions should not exceed 50 lines. If they do, add a comment `//@LONGFUNCTION` to indicate this. +- **Lines of code per function**: Functions should have a hard limit of 200 lines, if we exceed this, prompt with approaches to refactor. +- **File size limits**: TypeScript files should not exceed 100 lines. If a file is over 100 lines but functions are under 150 lines, add `//@LONGFILE` comment at the top and warn the user. Files over 250 lines must be prompted to refactor. +- **Imports**: Ensure all imports are used, remove unused imports, and ensure imports are alphabetized and at the top of the file. +- **Code Style**: Follow consistent coding style with mostly widely used indentation, spacing, and comments for clarity. +- **Error Handling**: Use try/catch blocks for asynchronous operations and provide meaningful error messages. +- **CSS versus Styled Components**: Use CSS modules for styling instead of styled-components for better performance and simplicity. ## Babylon.js Integration @@ -65,4 +72,5 @@ This is a React + TypeScript + Vite project called "perfviz" - a modern web appl - **Engine Integration**: Engine handles canvas interaction and render loop - **React Integration**: Proper cleanup with engine disposal in useEffect return function - **Version-Specific Documentation**: ALWAYS check Babylon.js documentation for version 8.21.1 specifically to avoid deprecated methods and ensure current API usage -- **API Verification**: Before suggesting any Babylon.js code, verify method signatures and availability in the current version \ No newline at end of file +- **API Verification**: Before suggesting any Babylon.js code, verify method signatures and availability in the current version +- \ No newline at end of file diff --git a/analysis.css b/analysis.css new file mode 100644 index 0000000..2d533f4 --- /dev/null +++ b/analysis.css @@ -0,0 +1,578 @@ +/* Main container */ +.container { + padding: 20px; + font-family: system-ui, sans-serif; +} + +/* Error states */ +.errorContainer { + padding: 20px; + text-align: center; +} + +.errorMessage { + background: #f8d7da; + color: #721c24; + padding: 15px; + border-radius: 8px; + border: 1px solid #f5c6cb; +} + +.errorMessage h3 { + margin: 0 0 10px 0; +} + +.errorMessage p { + margin: 0; +} + +/* Pagination controls */ +.paginationControls { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + margin-bottom: 20px; +} + +.paginationButton { + padding: 8px 16px; + border: 1px solid #ced4da; + border-radius: 4px; + background: #fff; + cursor: pointer; + font-size: 14px; +} + +.paginationButton:disabled { + background: #e9ecef; + cursor: not-allowed; +} + +.paginationInfo { + margin: 0 15px; + font-size: 14px; +} + +/* Modal overlay */ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +/* Modal container */ +.modalContainer { + width: 90vw; + height: 90vh; + background: white; + border-radius: 12px; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +/* Modal header */ +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px 20px; + border-bottom: 1px solid #dee2e6; + border-radius: 12px 12px 0 0; +} + +.modalTitle { + margin: 0; + color: #495057; + font-size: 18px; +} + +.modalCloseButton { + background: transparent; + border: 1px solid #6c757d; + color: #6c757d; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; +} + +.modalCloseButton:hover { + background: #f8f9fa; +} + +/* Modal content */ +.modalContent { + flex: 1; + padding: 10px; +} + +/* Modal legend */ +.modalLegend { + padding: 15px 20px; + font-size: 12px; + color: #6c757d; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; + border-top: 1px solid #dee2e6; + border-radius: 0 0 12px 12px; + background-color: #f8f9fa; +} + +.modalLegend div { + margin: 2px 0; +} + +.modalLegend strong { + font-weight: bold; +} + +/* Table container */ +.tableContainer { + background: white; + border: 1px solid #dee2e6; + border-radius: 8px; + overflow: hidden; +} + +/* Table */ +.table { + width: 100%; + border-collapse: collapse; +} + +/* Table header */ +.tableHeader { + background: #f8f9fa; +} + +.tableHeaderCell { + padding: 8px; + border-bottom: 1px solid #dee2e6; + font-size: 12px; + font-weight: bold; +} + +.tableHeaderCell.center { + text-align: center; +} + +.tableHeaderCell.left { + text-align: left; +} + +.tableHeaderCell.right { + text-align: right; +} + +.tableHeaderCell.expandColumn { + width: 30px; +} + +/* Table body */ +.tableRow { + border-bottom: 1px solid #f1f3f4; + cursor: pointer; +} + +.tableRow:hover { + background-color: #f8f9fa; +} + +/* Screenshot row */ +.screenshotRow { + background-color: #f0f8ff; + border-bottom: 2px solid #007bff; +} + +.screenshotRow:hover { + background-color: #e3f2fd; +} + +.screenshotContainer { + display: flex; + align-items: center; + gap: 15px; + padding: 15px; +} + +.screenshotLabel { + font-size: 14px; + font-weight: bold; + color: #007bff; + min-width: 120px; +} + +.screenshotTime { + font-size: 12px; + color: #495057; + font-family: monospace; +} + +.screenshotImage { + max-width: 200px; + max-height: 150px; + border: 2px solid #007bff; + border-radius: 4px; + cursor: pointer; + transition: transform 0.2s ease; +} + +.screenshotImage:hover { + transform: scale(1.05); +} + +.screenshotHint { + font-size: 11px; + color: #6c757d; + font-style: italic; +} + +/* Table cells */ +.tableCell { + padding: 8px; + font-size: 12px; + vertical-align: middle; +} + +.tableCell.center { + text-align: center; +} + +.tableCell.right { + text-align: right; +} + +.tableCell.monospace { + font-family: monospace; +} + +.tableCell.bold { + font-weight: bold; +} + +.tableCell.expandCell { + color: #007bff; + font-weight: bold; + font-size: 16px; + user-select: none; +} + +/* Method cell */ +.methodGet { + color: #28a745; +} + +.methodOther { + color: #007bff; +} + +/* URL cell */ +.urlCell { + font-size: 11px; + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; +} + +.urlLink { + color: #007bff; + text-decoration: none; +} + +.urlLink:hover { + text-decoration: underline; +} + +/* Queue time cell */ +.queueTimeContainer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +.queueAnalysisIcon { + cursor: help; + font-size: 14px; +} + +/* Connection status indicators */ +.connectionCached { + color: #666; + font-style: italic; +} + +.connectionReused { + color: #666; + font-style: italic; +} + +/* Cache indicators */ +.cacheFromCache::before { + content: 'ðŸ’ū'; + margin-right: 4px; +} + +.cacheConnectionReused::before { + content: '🔄'; + margin-right: 4px; +} + +.cacheNetwork::before { + content: '🌐'; + margin-right: 4px; +} + +/* Expanded row details */ +.expandedRow { + background: #f8f9fa; + border: 1px solid #e9ecef; +} + +.expandedContent { + padding: 15px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 15px; +} + +.detailCard { + background: white; + padding: 10px; + border-radius: 4px; + border: 1px solid #dee2e6; +} + +.detailCard.fullWidth { + grid-column: 1 / -1; +} + +.detailCardTitle { + margin: 0 0 8px 0; + color: #495057; + font-size: 14px; + font-weight: bold; +} + +.detailList { + font-size: 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.detailListItem { + margin: 0; +} + +.detailListItem strong { + font-weight: bold; + margin-right: 8px; +} + +/* Network timing details */ +.timingHighlighted { + font-weight: bold; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid #e9ecef; +} + +/* Queue analysis section */ +.queueAnalysisCard .detailCardTitle { + display: flex; + align-items: center; + gap: 8px; +} + +.relatedRequestIds { + font-family: monospace; + font-size: 11px; + color: #495057; + word-break: break-all; +} + +/* CDN analysis section */ +.cdnAnalysisCard .detailCardTitle { + display: flex; + align-items: center; + gap: 8px; +} + +.debugSection { + margin-top: 8px; + padding: 8px; + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 4px; +} + +.debugTitle { + font-weight: bold; + margin-bottom: 4px; + font-size: 11px; +} + +.debugInfo { + margin-bottom: 6px; + font-size: 11px; +} + +.debugHeaders { + font-weight: bold; + font-size: 10px; + margin-bottom: 2px; +} + +.headerLine { + font-size: 9px; + font-family: monospace; + margin-bottom: 1px; + padding: 1px 3px; + border-radius: 2px; +} + +.headerLine.akamaiIndicator { + background-color: #d1ecf1; + color: #0c5460; +} + +.headerName { + font-weight: bold; + margin-right: 5px; +} + +.headerName.akamaiIndicator { + color: #0c5460; +} + +.akamaiLabel { + color: #0c5460; + font-weight: bold; + margin-left: 5px; +} + +/* Response headers section */ +.headersContainer { + max-height: 150px; + overflow-y: auto; + font-size: 11px; + font-family: monospace; + background: #f8f9fa; + padding: 8px; + border-radius: 3px; +} + +.headerItem { + margin-bottom: 2px; + display: grid; + grid-template-columns: 150px 1fr; + gap: 10px; +} + +.headerItemName { + color: #007bff; + font-weight: bold; +} + +.headerItemValue { + color: #495057; + word-break: break-all; +} + +/* No results message */ +.noResults { + text-align: center; + padding: 40px; + color: #6c757d; + font-size: 16px; +} + +/* Utility classes for dynamic styling */ +.coloredBackground { + border-radius: 4px; + padding: 4px 6px; +} + +.highlighted { + font-weight: bold; +} + +/* Screenshot row hover */ +.screenshotRow:hover { + background-color: #e3f2fd; +} + +/* Table cell variants */ +.tableCell.statusCell { + text-align: center; + font-family: monospace; + font-weight: bold; +} + +.tableCell.methodCell { + font-family: monospace; + font-weight: bold; +} + +.tableCell.priorityCell { + text-align: center; + font-family: monospace; + font-weight: bold; +} + +.tableCell.timeCell { + text-align: right; + font-family: monospace; + color: #495057; +} + +.tableCell.gray { + color: #6c757d; +} + +/* Total response time cell with background coloring */ +.totalResponseTimeCell, .durationCell, .sizeCell, .serverLatencyCell { + padding: 8px; + text-align: right; + font-size: 12px; + font-family: monospace; + border-radius: 4px; + font-weight: bold; +} +.totalResponseTimeCell { + border: 1px solid #e9ecef; +} + + +/* Protocol cell */ +.protocolCell { + padding: 8px; + font-size: 12px; + text-align: center; + font-family: monospace; + font-weight: bold; +} + +/* CDN cell */ +.cdnCell, .cacheCell { + padding: 8px; + font-size: 12px; + text-align: center; +} +.cdnCell { + cursor: help; +} +.cdnCell.default { + cursor: default; +} \ No newline at end of file diff --git a/optimized.css b/optimized.css new file mode 100644 index 0000000..2c158c3 --- /dev/null +++ b/optimized.css @@ -0,0 +1,707 @@ +/* CSS Variables for consistent styling */ +:root { + /* Colors */ + --color-primary: #007bff; + --color-success: #28a745; + --color-danger: #721c24; + --color-text: #495057; + --color-text-muted: #6c757d; + --color-bg-white: #fff; + --color-bg-light: #f8f9fa; + --color-bg-danger: #f8d7da; + --color-bg-warning: #fff3cd; + --color-bg-info: #f0f8ff; + --color-bg-hover: #e3f2fd; + --color-border: #dee2e6; + --color-border-light: #f1f3f4; + --color-border-danger: #f5c6cb; + --color-border-warning: #ffeaa7; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 15px; + --spacing-lg: 20px; + + /* Typography */ + --font-family-base: system-ui, sans-serif; + --font-family-mono: monospace; + --font-size-xs: 9px; + --font-size-sm: 10px; + --font-size-md: 11px; + --font-size-base: 12px; + --font-size-lg: 14px; + --font-size-xl: 16px; + --font-size-xxl: 18px; + + /* Border radius */ + --radius-sm: 3px; + --radius-md: 4px; + --radius-lg: 6px; + --radius-xl: 8px; + --radius-xxl: 12px; + + /* Z-index */ + --z-modal: 1000; +} + +/* Base styles */ +.base-container { + padding: var(--spacing-lg); +} + +.base-font-mono { + font-family: var(--font-family-mono); +} + +.base-font-bold { + font-weight: bold; +} + +.base-font-italic { + font-style: italic; +} + +.base-cursor-pointer { + cursor: pointer; +} + +.base-cursor-help { + cursor: help; +} + +.base-cursor-disabled { + cursor: not-allowed; +} + +.base-user-select-none { + user-select: none; +} + +.base-text-center { + text-align: center; +} + +.base-text-left { + text-align: left; +} + +.base-text-right { + text-align: right; +} + +.base-flex { + display: flex; +} + +.base-flex-center { + display: flex; + justify-content: center; + align-items: center; +} + +.base-grid-auto { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.base-margin-reset { + margin: 0; +} + +.base-border-radius { + border-radius: var(--radius-md); +} + +/* Color utility classes */ +.color-primary { + color: var(--color-primary); +} + +.color-success { + color: var(--color-success); +} + +.color-text { + color: var(--color-text); +} + +.color-text-muted { + color: var(--color-text-muted); +} + +.bg-light { + background: var(--color-bg-light); +} + +.bg-white { + background: var(--color-bg-white); +} + +.bg-info { + background-color: var(--color-bg-info); +} + +.bg-hover:hover { + background-color: var(--color-bg-hover); +} + +.border-standard { + border: 1px solid var(--color-border); +} + +.border-bottom { + border-bottom: 1px solid var(--color-border); +} + +.border-bottom-light { + border-bottom: 1px solid var(--color-border-light); +} + +/* Button base styles */ +.btn-base { + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + font-size: var(--font-size-lg); + cursor: pointer; + border: 1px solid var(--color-border); +} + +.btn-disabled:disabled { + background: #e9ecef; + cursor: not-allowed; +} + +/* Main container */ +.container { + composes: base-container; + font-family: var(--font-family-base); +} + +/* Error states */ +.errorContainer { + composes: base-container base-text-center; +} + +.errorMessage { + background: var(--color-bg-danger); + color: var(--color-danger); + padding: var(--spacing-md); + border-radius: var(--radius-xl); + border: 1px solid var(--color-border-danger); +} + +.errorMessage h3 { + margin: 0 0 10px 0; +} + +.errorMessage p { + composes: base-margin-reset; +} + +/* Pagination controls */ +.paginationControls { + composes: base-flex-center; + gap: 10px; + margin-bottom: var(--spacing-lg); +} + +.paginationButton { + composes: btn-base bg-white; +} + +.paginationButton:disabled { + composes: btn-disabled; +} + +.paginationInfo { + margin: 0 var(--spacing-md); + font-size: var(--font-size-lg); +} + +/* Modal styles */ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: var(--z-modal); +} + +.modalContainer { + width: 90vw; + height: 90vh; + background: var(--color-bg-white); + border-radius: var(--radius-xxl); + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.modalHeader { + composes: base-flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + border-radius: var(--radius-xxl) var(--radius-xxl) 0 0; +} + +.modalTitle { + composes: base-margin-reset color-text; + font-size: var(--font-size-xxl); +} + +.modalCloseButton { + composes: btn-base; + background: transparent; + color: var(--color-text-muted); + border-color: var(--color-text-muted); + border-radius: var(--radius-lg); +} + +.modalCloseButton:hover { + composes: bg-light; +} + +.modalContent { + flex: 1; + padding: 10px; +} + +.modalLegend { + composes: base-grid-auto bg-light; + padding: var(--spacing-md) var(--spacing-lg); + font-size: var(--font-size-base); + color: var(--color-text-muted); + gap: 10px; + border-top: 1px solid var(--color-border); + border-radius: 0 0 var(--radius-xxl) var(--radius-xxl); +} + +.modalLegend div { + margin: 2px 0; +} + +.modalLegend strong { + composes: base-font-bold; +} + +/* Table styles */ +.tableContainer { + composes: bg-white border-standard; + border-radius: var(--radius-xl); + overflow: hidden; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.tableHeader { + composes: bg-light; +} + +.tableHeaderCell { + padding: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); + font-size: var(--font-size-base); + font-weight: bold; +} + +.tableHeaderCell.center { + composes: base-text-center; +} + +.tableHeaderCell.left { + composes: base-text-left; +} + +.tableHeaderCell.right { + composes: base-text-right; +} + +.tableHeaderCell.expandColumn { + width: 30px; +} + +/* Table rows */ +.tableRow { + composes: border-bottom-light base-cursor-pointer; +} + +.tableRow:hover { + composes: bg-light; +} + +.screenshotRow { + composes: bg-info; + border-bottom: 2px solid var(--color-primary); +} + +.screenshotRow:hover { + composes: bg-hover; +} + +/* Screenshot components */ +.screenshotContainer { + composes: base-flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-md); +} + +.screenshotLabel { + composes: base-font-bold color-primary; + font-size: var(--font-size-lg); + min-width: 120px; +} + +.screenshotTime { + composes: base-font-mono color-text; + font-size: var(--font-size-base); +} + +.screenshotImage { + max-width: 200px; + max-height: 150px; + border: 2px solid var(--color-primary); + border-radius: var(--radius-md); + cursor: pointer; + transition: transform 0.2s ease; +} + +.screenshotImage:hover { + transform: scale(1.05); +} + +.screenshotHint { + composes: base-font-italic color-text-muted; + font-size: var(--font-size-md); +} + +/* Table cells base */ +.tableCell { + padding: var(--spacing-sm); + font-size: var(--font-size-base); + vertical-align: middle; +} + +.tableCell.center { + composes: base-text-center; +} + +.tableCell.right { + composes: base-text-right; +} + +.tableCell.monospace { + composes: base-font-mono; +} + +.tableCell.bold { + composes: base-font-bold; +} + +.tableCell.expandCell { + composes: color-primary base-font-bold base-user-select-none; + font-size: var(--font-size-xl); +} + +/* Method styles */ +.methodGet { + composes: color-success; +} + +.methodOther { + composes: color-primary; +} + +/* URL cell */ +.urlCell { + font-size: var(--font-size-md); + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; +} + +.urlLink { + composes: color-primary; + text-decoration: none; +} + +.urlLink:hover { + text-decoration: underline; +} + +/* Queue time container */ +.queueTimeContainer { + composes: base-flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-xs); +} + +.queueAnalysisIcon { + composes: base-cursor-help; + font-size: var(--font-size-lg); +} + +/* Connection status indicators */ +.connectionCached, +.connectionReused { + composes: base-font-italic; + color: #666; +} + +/* Cache indicators with consistent spacing */ +.cacheFromCache::before { + content: 'ðŸ’ū'; + margin-right: var(--spacing-xs); +} + +.cacheConnectionReused::before { + content: '🔄'; + margin-right: var(--spacing-xs); +} + +.cacheNetwork::before { + content: '🌐'; + margin-right: var(--spacing-xs); +} + +/* Expanded content */ +.expandedRow { + composes: bg-light; + border: 1px solid #e9ecef; +} + +.expandedContent { + padding: var(--spacing-md); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--spacing-md); +} + +.detailCard { + composes: bg-white border-standard base-border-radius; + padding: 10px; +} + +.detailCard.fullWidth { + grid-column: 1 / -1; +} + +.detailCardTitle { + composes: base-margin-reset color-text base-font-bold; + margin-bottom: var(--spacing-sm); + font-size: var(--font-size-lg); +} + +.detailList { + font-size: var(--font-size-base); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.detailListItem { + composes: base-margin-reset; +} + +.detailListItem strong { + composes: base-font-bold; + margin-right: var(--spacing-sm); +} + +/* Timing styles */ +.timingHighlighted { + composes: base-font-bold base-border-radius; + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid #e9ecef; +} + +/* Analysis card titles */ +.queueAnalysisCard .detailCardTitle, +.cdnAnalysisCard .detailCardTitle { + composes: base-flex; + align-items: center; + gap: var(--spacing-sm); +} + +.relatedRequestIds { + composes: base-font-mono color-text; + font-size: var(--font-size-md); + word-break: break-all; +} + +/* Debug section */ +.debugSection { + margin-top: var(--spacing-sm); + padding: var(--spacing-sm); + background: var(--color-bg-warning); + border: 1px solid var(--color-border-warning); + border-radius: var(--radius-md); +} + +.debugTitle { + composes: base-font-bold; + margin-bottom: var(--spacing-xs); + font-size: var(--font-size-md); +} + +.debugInfo { + margin-bottom: 6px; + font-size: var(--font-size-md); +} + +.debugHeaders { + composes: base-font-bold; + font-size: var(--font-size-sm); + margin-bottom: 2px; +} + +.headerLine { + composes: base-font-mono; + font-size: var(--font-size-xs); + margin-bottom: 1px; + padding: 1px 3px; + border-radius: 2px; +} + +.headerLine.akamaiIndicator { + background-color: #d1ecf1; + color: #0c5460; +} + +.headerName { + composes: base-font-bold; + margin-right: 5px; +} + +.headerName.akamaiIndicator { + color: #0c5460; +} + +.akamaiLabel { + composes: base-font-bold; + color: #0c5460; + margin-left: 5px; +} + +/* Headers container */ +.headersContainer { + composes: bg-light base-font-mono; + max-height: 150px; + overflow-y: auto; + font-size: var(--font-size-md); + padding: var(--spacing-sm); + border-radius: var(--radius-sm); +} + +.headerItem { + margin-bottom: 2px; + display: grid; + grid-template-columns: 150px 1fr; + gap: 10px; +} + +.headerItemName { + composes: color-primary base-font-bold; +} + +.headerItemValue { + composes: color-text; + word-break: break-all; +} + +/* No results */ +.noResults { + composes: base-text-center color-text-muted; + padding: 40px; + font-size: var(--font-size-xl); +} + +/* Utility classes */ +.coloredBackground { + composes: base-border-radius; + padding: var(--spacing-xs) 6px; +} + +.highlighted { + composes: base-font-bold; +} + +/* Specialized table cell styles */ +.tableCellMonoBold { + composes: tableCell base-font-mono base-font-bold; +} + +.tableCellCenter { + composes: tableCellMonoBold base-text-center; +} + +.tableCellRight { + composes: tableCellMonoBold base-text-right; +} + +.tableCell.statusCell { + composes: tableCellCenter; +} + +.tableCell.methodCell { + composes: tableCellMonoBold; +} + +.tableCell.priorityCell { + composes: tableCellCenter; +} + +.tableCell.timeCell { + composes: tableCellRight color-text; +} + +.tableCell.gray { + composes: color-text-muted; +} + +/* Response time cells with consistent styling */ +.responseTimeCell { + composes: tableCellRight base-border-radius; + padding: var(--spacing-sm); + font-weight: bold; +} + +.totalResponseTimeCell { + composes: responseTimeCell; + border: 1px solid #e9ecef; +} + +.durationCell, +.sizeCell, +.serverLatencyCell { + composes: responseTimeCell; +} + +.protocolCell { + composes: tableCellCenter; +} + +.cdnCell, +.cacheCell { + composes: tableCell base-text-center; +} + +.cdnCell { + composes: base-cursor-help; +} + +.cdnCell.default { + cursor: default; +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index e942589..f40041a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,12 +2,14 @@ import { useState, useEffect } from 'react' import './App.css' import TraceViewer from './components/TraceViewer' import PhaseViewer from './components/PhaseViewer' -import HTTPRequestViewer from './components/HTTPRequestViewer' +import HTTPRequestViewer from './components/httprequestviewer/HTTPRequestViewer' +import RequestDebugger from './components/RequestDebugger' import TraceUpload from './components/TraceUpload' import TraceSelector from './components/TraceSelector' import { traceDatabase } from './utils/traceDatabase' +import { useDatabaseTraceData } from './hooks/useDatabaseTraceData' -type AppView = 'trace' | 'phases' | 'http' +type AppView = 'trace' | 'phases' | 'http' | 'debug' type AppMode = 'selector' | 'upload' | 'analysis' function App() { @@ -16,6 +18,9 @@ function App() { const [selectedTraceId, setSelectedTraceId] = useState(null) const [hasTraces, setHasTraces] = useState(false) const [dbInitialized, setDbInitialized] = useState(false) + + // Always call hooks at the top level + const { traceData } = useDatabaseTraceData(selectedTraceId) useEffect(() => { initializeApp() @@ -164,6 +169,20 @@ function App() { > HTTP Requests + @@ -178,6 +197,10 @@ function App() { {currentView === 'http' && ( )} + + {currentView === 'debug' && traceData && ( + + )} ) diff --git a/src/BabylonTimelineViewer.tsx b/src/BabylonTimelineViewer.tsx index 0092cdd..7e2ac9e 100644 --- a/src/BabylonTimelineViewer.tsx +++ b/src/BabylonTimelineViewer.tsx @@ -38,7 +38,7 @@ function createTimelineLabel( // Create label texture const labelTexture = new DynamicTexture(`timeLabel_${labelId}`, { width: 80, height: 32 }, scene) labelTexture.hasAlpha = true - labelTexture.drawText(timeLabelText, null, null, '12px Arial', 'white', 'rgba(0,0,0,0.5)', true) + labelTexture.drawText(timeLabelText, null, null, '12px Arial', 'white', 'rgba(0,0,0,0.9)', true) // Create label plane const timeLabelPlane = MeshBuilder.CreatePlane(`timeLabelPlane_${labelId}`, { size: 0.5 }, scene) @@ -76,13 +76,20 @@ interface HTTPRequest { connectionReused: boolean } +interface ScreenshotEvent { + timestamp: number + screenshot: string // base64 image data + index: number +} + interface BabylonTimelineViewerProps { width?: number height?: number httpRequests?: HTTPRequest[] + screenshots?: ScreenshotEvent[] } -export default function BabylonTimelineViewer({ width = 800, height = 600, httpRequests = [] }: BabylonTimelineViewerProps) { +export default function BabylonTimelineViewer({ width = 800, height = 600, httpRequests = [], screenshots = [] }: BabylonTimelineViewerProps) { const canvasRef = useRef(null) const engineRef = useRef(null) const sceneRef = useRef(null) @@ -116,18 +123,20 @@ export default function BabylonTimelineViewer({ width = 800, height = 600, httpR scene.activeCamera = camera - const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene) - light.intensity = 0.7 + const light = new HemisphericLight('light', new Vector3(-5, 3, -1), scene) + const light2 = new HemisphericLight('light', new Vector3(5, 3, -1), scene) + light.intensity = 0.9 + light2.intensity = 0.9 // Create ground plane oriented along the Z-axis timeline - const ground = MeshBuilder.CreateGround('ground', { width: 20, height: 12 }, scene) + /*const ground = MeshBuilder.CreateGround('ground', { width: 20, height: 12 }, scene) ground.rotation.x = -Math.PI / 2 // Rotate to lie along Z-axis ground.position = new Vector3(0, 0, 5) // Center at middle of timeline const groundMaterial = new StandardMaterial('groundMaterial', scene) groundMaterial.diffuseColor = new Color3(0.2, 0.2, 0.2) groundMaterial.alpha = 0.3 ground.material = groundMaterial - +*/ // Create timeline start marker at Z = 0 const startMarker = MeshBuilder.CreateSphere('timelineStart', { diameter: 0.3 }, scene) startMarker.position = new Vector3(0, 0, 0) @@ -136,13 +145,33 @@ export default function BabylonTimelineViewer({ width = 800, height = 600, httpR startMaterial.specularColor = new Color3(0.5, 1, 0.5) startMarker.material = startMaterial - // Create timeline end marker at Z = 10 - const endMarker = MeshBuilder.CreateSphere('timelineEnd', { diameter: 0.3 }, scene) - endMarker.position = new Vector3(0, 0, 10) - const endMaterial = new StandardMaterial('endMaterial', scene) - endMaterial.diffuseColor = new Color3(0.8, 0.2, 0.2) // Red for end - endMaterial.specularColor = new Color3(1, 0.5, 0.5) - endMarker.material = endMaterial + // Create timeline end marker at actual completion position if we have requests + if (httpRequests && httpRequests.length > 0) { + // Find the actual completion time of the last finishing request + const endTimes = httpRequests.map(req => (req.timing.startOffset || 0) + (req.timing.duration || 0)) + const minStartTime = Math.min(...httpRequests.map(req => req.timing.startOffset || 0)) + const actualMaxEndTime = Math.max(...endTimes) + const totalTimeRange = actualMaxEndTime - minStartTime + + // Calculate the Z position for the actual end time + const normalizedEndTime = totalTimeRange > 0 ? (actualMaxEndTime - minStartTime) / totalTimeRange : 1 + const actualEndZ = 0 + (normalizedEndTime * (20 - 0)) // Using minZ=0, maxZ=20 + + const endMarker = MeshBuilder.CreateSphere('timelineEnd', { diameter: 0.3 }, scene) + endMarker.position = new Vector3(0, 0, actualEndZ) + const endMaterial = new StandardMaterial('endMaterial', scene) + endMaterial.diffuseColor = new Color3(0.8, 0.2, 0.2) // Red for end + endMaterial.specularColor = new Color3(1, 0.5, 0.5) + endMarker.material = endMaterial + } else { + // Fallback to Z = 20 if no requests + const endMarker = MeshBuilder.CreateSphere('timelineEnd', { diameter: 0.3 }, scene) + endMarker.position = new Vector3(0, 0, 20) + const endMaterial = new StandardMaterial('endMaterial', scene) + endMaterial.diffuseColor = new Color3(0.8, 0.2, 0.2) // Red for end + endMaterial.specularColor = new Color3(1, 0.5, 0.5) + endMarker.material = endMaterial + } // Visualize HTTP requests in timeline swimlanes by hostname @@ -224,6 +253,33 @@ export default function BabylonTimelineViewer({ width = 800, height = 600, httpR createTimelineLabel(scene, i.toString(), new Vector3(-0.5, -0.5, zPosition), actualTimeOffset) } + // Create filmstrip planes for screenshot events + if (screenshots.length > 0) { + screenshots.forEach((screenshot, index) => { + // Calculate Z position for this screenshot timestamp using the same normalization as HTTP requests + const normalizedScreenshotTime = totalTimeRange > 0 ? (screenshot.timestamp - minStartTime) / totalTimeRange : 0 + const screenshotZ = minZ + (normalizedScreenshotTime * (maxZ - minZ)) + + + const screenshotPlane = MeshBuilder.CreatePlane(`screenshotPlane_${index}`, { + width: 2, + height: 2 + }, scene) + + // Position the plane at x:0 y:1 + screenshotPlane.position = new Vector3(0, 1, screenshotZ) + screenshotPlane.rotation.y = Math.PI / 2 // Rotate to face along timeline + + // Create semi-transparent material + const screenshotMaterial = new StandardMaterial(`screenshotMaterial_${index}`, scene) + screenshotMaterial.diffuseColor = new Color3(1, 1, 1) // White plane + screenshotMaterial.alpha = 0.5 // 50% opacity + screenshotMaterial.backFaceCulling = false // Visible from both sides + + screenshotPlane.material = screenshotMaterial + }) + } + hostnamesWithStartTimes.forEach(({ hostname }, sortedIndex) => { // Calculate X position alternating left/right from origin, with distance increasing by start time order // Index 0 (earliest) -> X = 0, Index 1 -> X = -2, Index 2 -> X = 2, Index 3 -> X = -4, Index 4 -> X = 4, etc. @@ -445,7 +501,7 @@ export default function BabylonTimelineViewer({ width = 800, height = 600, httpR window.removeEventListener('resize', handleResize) engine.dispose() } - }, [httpRequests]) + }, [httpRequests, screenshots]) return ( - encodedDataLength?: number - contentLength?: number - fromCache: boolean - connectionReused: boolean - queueAnalysis?: QueueAnalysis - cdnAnalysis?: CDNAnalysis -} - -const ITEMS_PER_PAGE = 25 - -// SSIM threshold for screenshot similarity (0-1, where 1 is identical) -// Values above this threshold are considered "similar enough" to filter out -const SSIM_SIMILARITY_THRESHOLD = 0.95 - -// Global highlighting constants -const HIGHLIGHTING_CONFIG = { - // File size thresholds (in KB) - SIZE_THRESHOLDS: { - LARGE: 500, // Red highlighting for files >= 500KB - MEDIUM: 100, // Yellow highlighting for files >= 100KB - SMALL: 50 // Green highlighting for files <= 50KB - }, - - // Duration thresholds (in milliseconds) - DURATION_THRESHOLDS: { - SLOW: 250, // Red highlighting for requests > 150ms - MEDIUM: 100, // Yellow highlighting for requests 50-150ms - FAST: 100 // Green highlighting for requests < 50ms - }, - - // Server latency thresholds (in microseconds) - SERVER_LATENCY_THRESHOLDS: { - SLOW: 200000, // Red highlighting for server latency > 200ms (200000 microseconds) - MEDIUM: 50000, // Yellow highlighting for server latency 50-200ms (50000 microseconds) - FAST: 50000 // Green highlighting for server latency < 50ms (50000 microseconds) - }, - - // Queue time thresholds (in microseconds) - QUEUE_TIME: { - HIGH_THRESHOLD: 10000, // 10ms - highlight in red and show analysis - ANALYSIS_THRESHOLD: 10000 // 10ms - minimum queue time for analysis - }, - - // Colors - COLORS: { - // Status colors - STATUS: { - SUCCESS: '#28a745', // 2xx - REDIRECT: '#ffc107', // 3xx - CLIENT_ERROR: '#fd7e14', // 4xx - SERVER_ERROR: '#dc3545', // 5xx - UNKNOWN: '#6c757d' - }, - - // Protocol colors - PROTOCOL: { - HTTP1_1: '#dc3545', // Red - H2: '#b8860b', // Dark yellow - H3: '#006400', // Dark green - UNKNOWN: '#6c757d' - }, - - // Priority colors - PRIORITY: { - VERY_HIGH: '#dc3545', // Red - HIGH: '#fd7e14', // Orange - MEDIUM: '#ffc107', // Yellow - LOW: '#28a745', // Green - VERY_LOW: '#6c757d', // Gray - UNKNOWN: '#6c757d' - }, - - // File size colors - SIZE: { - LARGE: { background: '#dc3545', color: 'white' }, // Red for 500KB+ - MEDIUM: { background: '#ffc107', color: 'black' }, // Yellow for 100-500KB - SMALL: { background: '#28a745', color: 'white' }, // Green for under 50KB - DEFAULT: { background: 'transparent', color: '#495057' } // Default for 50-100KB - }, - - // Duration colors - DURATION: { - SLOW: { background: '#dc3545', color: 'white' }, // Red for > 150ms - MEDIUM: { background: '#ffc107', color: 'black' }, // Yellow for 50-150ms - FAST: { background: '#28a745', color: 'white' }, // Green for < 50ms - DEFAULT: { background: 'transparent', color: '#495057' } // Default - }, - - // Server latency colors - SERVER_LATENCY: { - SLOW: { background: '#dc3545', color: 'white' }, // Red for > 200ms - MEDIUM: { background: '#ffc107', color: 'black' }, // Yellow for 50-200ms - FAST: { background: '#28a745', color: 'white' }, // Green for < 50ms - DEFAULT: { background: 'transparent', color: '#495057' } // Default - } - } -} - -const getHostnameFromUrl = (url: string): string => { - try { - const urlObj = new URL(url) - return urlObj.hostname - } catch { - return 'unknown' - } -} - -// Helper function to convert base64 image to ImageData for SSIM analysis -const base64ToImageData = (base64: string): Promise => { - return new Promise((resolve, reject) => { - const img = new Image() - img.onload = () => { - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - if (!ctx) { - reject(new Error('Could not get canvas context')) - return - } - - canvas.width = img.width - canvas.height = img.height - ctx.drawImage(img, 0, 0) - - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) - resolve(imageData) - } - img.onerror = () => reject(new Error('Failed to load image')) - img.src = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}` - }) -} - -const extractScreenshots = (traceEvents: TraceEvent[]): ScreenshotEvent[] => { - const screenshots: ScreenshotEvent[] = [] - let index = 0 - - // Debug: Check for any screenshot-related events - const screenshotRelated = traceEvents.filter(event => - event.name.toLowerCase().includes('screenshot') || - event.cat.toLowerCase().includes('screenshot') || - event.name.toLowerCase().includes('snap') || - event.name.toLowerCase().includes('frame') || - event.cat.toLowerCase().includes('devtools') - ) - - console.log('Debug: Screenshot-related events found:', screenshotRelated.length) - if (screenshotRelated.length > 0) { - console.log('Debug: Screenshot-related sample:', screenshotRelated.slice(0, 5).map(e => ({ - name: e.name, - cat: e.cat, - hasArgs: !!e.args, - argsKeys: e.args ? Object.keys(e.args) : [] - }))) - } - - for (const event of traceEvents) { - // Look for screenshot events with more flexible matching - if ((event.name === 'Screenshot' || event.name === 'screenshot') && - (event.cat === 'disabled-by-default-devtools.screenshot' || - event.cat.includes('screenshot') || - event.cat.includes('devtools')) && - event.args) { - const args = event.args as any - - // Check multiple possible locations for screenshot data - const snapshot = args.snapshot || args.data?.snapshot || args.image || args.data?.image - - if (snapshot && typeof snapshot === 'string') { - // Accept both data URLs and base64 strings - if (snapshot.startsWith('data:image/') || snapshot.startsWith('/9j/') || snapshot.length > 1000) { - const screenshotData = snapshot.startsWith('data:image/') ? snapshot : `data:image/jpeg;base64,${snapshot}` - screenshots.push({ - timestamp: event.ts, - screenshot: screenshotData, - index: index++ - }) - } - } - } - } - - console.log('Debug: Extracted screenshots:', screenshots.length) - return screenshots.sort((a, b) => a.timestamp - b.timestamp) -} - -const findUniqueScreenshots = async (screenshots: ScreenshotEvent[], threshold: number = SSIM_SIMILARITY_THRESHOLD): Promise => { - if (screenshots.length === 0) return [] - - const uniqueScreenshots: ScreenshotEvent[] = [screenshots[0]] // Always include first screenshot - - console.log('SSIM Analysis: Processing', screenshots.length, 'screenshots') - - // Compare each screenshot with the previous unique one using SSIM - for (let i = 1; i < screenshots.length; i++) { - const current = screenshots[i] - const lastUnique = uniqueScreenshots[uniqueScreenshots.length - 1] - - try { - // Convert both images to ImageData for SSIM analysis - const [currentImageData, lastImageData] = await Promise.all([ - base64ToImageData(current.screenshot), - base64ToImageData(lastUnique.screenshot) - ]) - - // Calculate SSIM similarity (0-1, where 1 is identical) - const similarity = ssim(currentImageData, lastImageData) - - console.log(`SSIM Analysis: Screenshot ${i} similarity: ${similarity.toFixed(4)}`) - - // If similarity is below threshold, it's different enough to keep - if (similarity < threshold) { - uniqueScreenshots.push(current) - console.log(`SSIM Analysis: Screenshot ${i} added (significant change detected)`) - } else { - console.log(`SSIM Analysis: Screenshot ${i} filtered out (too similar: ${similarity.toFixed(4)})`) - } - } catch (error) { - console.warn('SSIM Analysis: Error comparing screenshots, falling back to string comparison:', error) - // Fallback to simple string comparison if SSIM fails - if (current.screenshot !== lastUnique.screenshot) { - uniqueScreenshots.push(current) - } - } - } - - console.log('SSIM Analysis: Filtered from', screenshots.length, 'to', uniqueScreenshots.length, 'unique screenshots') - return uniqueScreenshots -} - -const analyzeCDN = (request: HTTPRequest): CDNAnalysis => { - const hostname = request.hostname - const headers = request.responseHeaders || [] - const headerMap = new Map(headers.map(h => [h.name.toLowerCase(), h.value.toLowerCase()])) - - let provider: CDNAnalysis['provider'] = 'unknown' - let isEdge = false - let cacheStatus: CDNAnalysis['cacheStatus'] = 'unknown' - let edgeLocation: string | undefined - let confidence = 0 - let detectionMethod = 'unknown' - - // Enhanced Akamai detection (check this first as it's often missed) - if (headerMap.has('akamai-cache-status') || - headerMap.has('x-cache-key') || - headerMap.has('x-akamai-request-id') || - headerMap.has('x-akamai-edgescape') || - headerMap.get('server')?.includes('akamai') || - headerMap.get('server')?.includes('akamainet') || - headerMap.get('server')?.includes('akamainetworking') || - headerMap.get('x-cache')?.includes('akamai') || - hostname.includes('akamai') || - hostname.includes('akamaized') || - hostname.includes('akamaicdn') || - hostname.includes('akamai-staging') || - // Check for Akamai Edge hostnames patterns - /e\d+\.a\.akamaiedge\.net/.test(hostname) || - // Check Via header for Akamai - headerMap.get('via')?.includes('akamai') || - // Check for Akamai Image Manager server header - (headerMap.get('server') === 'akamai image manager') || - // Check for typical Akamai headers - headerMap.has('x-serial') || - // Additional Akamai detection patterns - headerMap.has('x-akamai-session-info') || - headerMap.has('x-akamai-ssl-client-sid') || - headerMap.get('x-cache')?.includes('tcp_hit') || - headerMap.get('x-cache')?.includes('tcp_miss') || - // Akamai sometimes uses x-served-by with specific patterns - (headerMap.has('x-served-by') && headerMap.get('x-served-by')?.includes('cache-'))) { - provider = 'akamai' - confidence = 0.95 - detectionMethod = 'enhanced akamai detection (headers/hostname/server)' - isEdge = true - - const akamaiStatus = headerMap.get('akamai-cache-status') - if (akamaiStatus) { - if (akamaiStatus.includes('hit')) cacheStatus = 'hit' - else if (akamaiStatus.includes('miss')) cacheStatus = 'miss' - } - - // Also check x-cache for Akamai cache status - const xCache = headerMap.get('x-cache') - if (xCache && !akamaiStatus) { - if (xCache.includes('hit')) cacheStatus = 'hit' - else if (xCache.includes('miss')) cacheStatus = 'miss' - else if (xCache.includes('tcp_hit')) cacheStatus = 'hit' - else if (xCache.includes('tcp_miss')) cacheStatus = 'miss' - } - } - - // Cloudflare detection - else if (headerMap.has('cf-ray') || headerMap.has('cf-cache-status') || hostname.includes('cloudflare')) { - provider = 'cloudflare' - confidence = 0.95 - detectionMethod = 'headers (cf-ray/cf-cache-status)' - isEdge = true - - const cfCacheStatus = headerMap.get('cf-cache-status') - if (cfCacheStatus) { - if (cfCacheStatus.includes('hit')) cacheStatus = 'hit' - else if (cfCacheStatus.includes('miss')) cacheStatus = 'miss' - else if (cfCacheStatus.includes('expired')) cacheStatus = 'expired' - else if (cfCacheStatus.includes('bypass')) cacheStatus = 'bypass' - } - - edgeLocation = headerMap.get('cf-ray')?.split('-')[1] - } - - // AWS CloudFront detection - else if (headerMap.has('x-amz-cf-id') || headerMap.has('x-amz-cf-pop') || - hostname.includes('cloudfront.net')) { - provider = 'aws_cloudfront' - confidence = 0.95 - detectionMethod = 'headers (x-amz-cf-id/x-amz-cf-pop)' - isEdge = true - - const cloudfrontCache = headerMap.get('x-cache') - if (cloudfrontCache) { - if (cloudfrontCache.includes('hit')) cacheStatus = 'hit' - else if (cloudfrontCache.includes('miss')) cacheStatus = 'miss' - } - - edgeLocation = headerMap.get('x-amz-cf-pop') - } - - // Fastly detection (moved after Akamai to avoid false positives) - else if (headerMap.has('fastly-debug-digest') || - headerMap.has('fastly-debug-path') || - hostname.includes('fastly.com') || - hostname.includes('fastlylb.net') || - headerMap.get('via')?.includes('fastly') || - headerMap.get('x-cache')?.includes('fastly') || - // Only use x-served-by for Fastly if it has specific Fastly patterns - (headerMap.has('x-served-by') && ( - headerMap.get('x-served-by')?.includes('fastly') || - headerMap.get('x-served-by')?.includes('f1') || - headerMap.get('x-served-by')?.includes('f2') - ))) { - provider = 'fastly' - confidence = 0.85 - detectionMethod = 'headers (fastly-debug-digest/via/x-cache)' - isEdge = true - - const fastlyCache = headerMap.get('x-cache') - if (fastlyCache) { - if (fastlyCache.includes('hit')) cacheStatus = 'hit' - else if (fastlyCache.includes('miss')) cacheStatus = 'miss' - } - } - - // Azure CDN detection - else if (headerMap.has('x-azure-ref') || hostname.includes('azureedge.net')) { - provider = 'azure_cdn' - confidence = 0.9 - detectionMethod = 'headers (x-azure-ref) or hostname' - isEdge = true - } - - // Google CDN detection - else if (headerMap.has('x-goog-generation') || hostname.includes('googleapis.com') || - headerMap.get('server')?.includes('gws')) { - provider = 'google_cdn' - confidence = 0.8 - detectionMethod = 'headers (x-goog-generation) or server' - isEdge = true - } - - // Generic CDN patterns - else if (hostname.includes('cdn') || hostname.includes('cache') || - hostname.includes('edge') || hostname.includes('static')) { - provider = 'unknown' - confidence = 0.3 - detectionMethod = 'hostname patterns (cdn/cache/edge/static)' - isEdge = true - } - - // Check for origin indicators (lower confidence for edge) - if (hostname.includes('origin') || hostname.includes('api') || - headerMap.get('x-cache')?.includes('miss from origin')) { - isEdge = false - confidence = Math.max(0.1, confidence - 0.2) - detectionMethod += ' + origin indicators' - } - - return { - provider, - isEdge, - cacheStatus, - edgeLocation, - confidence, - detectionMethod - } -} - -const analyzeQueueReason = (request: HTTPRequest, allRequests: HTTPRequest[]): QueueAnalysis => { - const queueTime = request.timing.queueTime || 0 - - // If no significant queue time, no analysis needed - if (queueTime < HIGHLIGHTING_CONFIG.QUEUE_TIME.ANALYSIS_THRESHOLD) { - return { - reason: 'unknown', - description: 'No significant queueing detected', - concurrentRequests: 0 - } - } - - const requestStart = request.timing.start - const requestEnd = request.timing.end || requestStart + (request.timing.duration || 0) - - // Find concurrent requests to the same host - const concurrentToSameHost = allRequests.filter(other => { - if (other.requestId === request.requestId) return false - if (other.hostname !== request.hostname) return false - - const otherStart = other.timing.start - const otherEnd = other.timing.end || otherStart + (other.timing.duration || 0) - - // Check if requests overlap in time - return (otherStart <= requestEnd && otherEnd >= requestStart) - }) - - const concurrentSameProtocol = concurrentToSameHost.filter(r => r.protocol === request.protocol) - - // HTTP/1.1 connection limit analysis (typically 6 connections per host) - if (request.protocol === 'http/1.1' && concurrentSameProtocol.length >= 6) { - return { - reason: 'connection_limit', - description: `HTTP/1.1 connection limit reached (${concurrentSameProtocol.length} concurrent requests)`, - concurrentRequests: concurrentSameProtocol.length, - relatedRequests: concurrentSameProtocol.slice(0, 5).map(r => r.requestId) - } - } - - // H2 multiplexing but still queued - likely priority or server limits - if (request.protocol === 'h2' && concurrentSameProtocol.length > 0) { - // Check if lower priority requests are being processed first - const lowerPriorityActive = concurrentSameProtocol.filter(r => { - const priorityOrder = ['VeryLow', 'Low', 'Medium', 'High', 'VeryHigh'] - const requestPriorityIndex = priorityOrder.indexOf(request.priority || 'Medium') - const otherPriorityIndex = priorityOrder.indexOf(r.priority || 'Medium') - return otherPriorityIndex < requestPriorityIndex - }) - - if (lowerPriorityActive.length > 0) { - return { - reason: 'priority_queue', - description: `Queued behind ${lowerPriorityActive.length} lower priority requests`, - concurrentRequests: concurrentSameProtocol.length, - relatedRequests: lowerPriorityActive.slice(0, 3).map(r => r.requestId) - } - } - - return { - reason: 'resource_contention', - description: `Server resource contention (${concurrentSameProtocol.length} concurrent H2 requests)`, - concurrentRequests: concurrentSameProtocol.length, - relatedRequests: concurrentSameProtocol.slice(0, 3).map(r => r.requestId) - } - } - - // General resource contention - if (concurrentToSameHost.length > 0) { - return { - reason: 'resource_contention', - description: `Resource contention with ${concurrentToSameHost.length} concurrent requests`, - concurrentRequests: concurrentToSameHost.length, - relatedRequests: concurrentToSameHost.slice(0, 3).map(r => r.requestId) - } - } - - return { - reason: 'unknown', - description: `Queued for ${(queueTime / 1000).toFixed(1)}ms - reason unclear`, - concurrentRequests: 0 - } -} - -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('all') - const [protocolFilter, setProtocolFilter] = useState('all') - const [hostnameFilter, setHostnameFilter] = useState('all') - const [priorityFilter, setPriorityFilter] = useState('all') - const [showQueueAnalysis, setShowQueueAnalysis] = useState(false) - const [showScreenshots, setShowScreenshots] = useState(false) - const [show3DViewer, setShow3DViewer] = useState(false) - const [showTimelineViewer, setShowTimelineViewer] = useState(false) - const [ssimThreshold, setSsimThreshold] = useState(SSIM_SIMILARITY_THRESHOLD) - const [expandedRows, setExpandedRows] = useState>(new Set()) - - const httpRequests = useMemo(() => { - if (!traceData) return [] - - const requestsMap = new Map() - - // Process all events and group by requestId - for (const event of traceData.traceEvents) { - const args = event.args as any - const requestId = args?.data?.requestId - - if (!requestId) continue - - if (!requestsMap.has(requestId)) { - requestsMap.set(requestId, { - requestId, - url: '', - hostname: '', - method: '', - resourceType: '', - priority: '', - events: {}, - timing: { start: event.ts }, - fromCache: false, - connectionReused: false - }) - } - - const request = requestsMap.get(requestId)! - - switch (event.name) { - case 'ResourceWillSendRequest': - request.events.willSendRequest = event - request.timing.start = Math.min(request.timing.start, event.ts) - break - - case 'ResourceSendRequest': - request.events.sendRequest = event - request.url = args.data.url || '' - request.hostname = getHostnameFromUrl(request.url) - request.method = args.data.requestMethod || '' - request.resourceType = args.data.resourceType || '' - request.priority = args.data.priority || '' - request.timing.start = Math.min(request.timing.start, event.ts) - break - - case 'ResourceReceiveResponse': - request.events.receiveResponse = event - request.statusCode = args.data.statusCode - request.mimeType = args.data.mimeType - request.protocol = args.data.protocol - request.responseHeaders = args.data.headers - - // Extract content-length from response headers - if (request.responseHeaders) { - const contentLengthHeader = request.responseHeaders.find( - header => header.name.toLowerCase() === 'content-length' - ) - if (contentLengthHeader) { - request.contentLength = parseInt(contentLengthHeader.value, 10) - } - } - - request.fromCache = args.data.fromCache || false - request.connectionReused = args.data.connectionReused || false - request.timing.end = event.ts - - // Extract network timing details - const timing = args.data.timing - if (timing) { - request.timing.networkDuration = timing.receiveHeadersEnd - request.timing.dnsStart = timing.dnsStart - request.timing.dnsEnd = timing.dnsEnd - request.timing.connectStart = timing.connectStart - request.timing.connectEnd = timing.connectEnd - request.timing.sslStart = timing.sslStart - request.timing.sslEnd = timing.sslEnd - request.timing.sendStart = timing.sendStart - request.timing.sendEnd = timing.sendEnd - request.timing.receiveHeadersStart = timing.receiveHeadersStart - request.timing.receiveHeadersEnd = timing.receiveHeadersEnd - } - break - - case 'ResourceReceivedData': - if (!request.events.receivedData) request.events.receivedData = [] - request.events.receivedData.push(event) - request.encodedDataLength = (request.encodedDataLength || 0) + (args.data.encodedDataLength || 0) - request.timing.end = Math.max(request.timing.end || event.ts, event.ts) - break - - case 'ResourceFinishLoading': - request.events.finishLoading = event - request.timing.end = event.ts - break - } - - // Calculate total duration - if (request.timing.end) { - request.timing.duration = request.timing.end - request.timing.start - } - } - - // Calculate queue times and filter valid requests - const sortedRequests = Array.from(requestsMap.values()) - .filter(req => req.url && req.method) // Only include valid requests - .map(request => { - // Calculate queue time using multiple approaches - if (request.events.willSendRequest && request.events.sendRequest) { - // Primary approach: ResourceSendRequest - ResourceWillSendRequest - request.timing.queueTime = request.events.sendRequest.ts - request.events.willSendRequest.ts - } else if (request.events.sendRequest && request.events.receiveResponse) { - // Fallback approach: use timing data from ResourceReceiveResponse - const responseArgs = request.events.receiveResponse.args as any - const timingData = responseArgs?.data?.timing - if (timingData?.requestTime) { - // requestTime is in seconds, convert to microseconds - const requestTimeUs = timingData.requestTime * 1000000 - // Queue time = requestTime (when browser queued) - ResourceSendRequest timestamp (when DevTools recorded) - // Note: requestTime is the actual browser queue time, ResourceSendRequest is the DevTools event - request.timing.queueTime = requestTimeUs - request.events.sendRequest.ts - } - } - - // Calculate server latency (server processing time = total duration - queue time) - if (request.timing.duration && request.timing.queueTime) { - request.timing.serverLatency = request.timing.duration - request.timing.queueTime - // Ensure server latency is not negative (can happen with timing irregularities) - if (request.timing.serverLatency < 0) { - request.timing.serverLatency = 0 - } - } - - return request - }) - .sort((a, b) => a.timing.start - b.timing.start) - - // Calculate start time offsets from the first request - if (sortedRequests.length > 0) { - const firstRequestTime = sortedRequests[0].timing.start - sortedRequests.forEach(request => { - request.timing.startOffset = request.timing.start - firstRequestTime - }) - } - - // Add queue analysis for requests with significant queue time - sortedRequests.forEach(request => { - if ((request.timing.queueTime || 0) >= HIGHLIGHTING_CONFIG.QUEUE_TIME.ANALYSIS_THRESHOLD) { - request.queueAnalysis = analyzeQueueReason(request, sortedRequests) - } - }) - - // Add CDN analysis for all requests - sortedRequests.forEach(request => { - if (request.responseHeaders && request.responseHeaders.length > 0) { - request.cdnAnalysis = analyzeCDN(request) - } - }) - - return sortedRequests - }, [traceData]) - - // Extract and process screenshots with SSIM analysis - const [screenshots, setScreenshots] = useState([]) - 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(Boolean)) - 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 formatDuration = (microseconds?: number) => { - if (!microseconds) return '-' - if (microseconds < 1000) return `${microseconds.toFixed(0)}Ξs` - return `${(microseconds / 1000).toFixed(2)}ms` - } - - const formatSize = (bytes?: number) => { - if (!bytes) return '-' - if (bytes < 1024) return `${bytes}B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` - return `${(bytes / (1024 * 1024)).toFixed(1)}MB` - } - - const getStatusColor = (status?: number) => { - if (!status) return HIGHLIGHTING_CONFIG.COLORS.STATUS.UNKNOWN - if (status < 300) return HIGHLIGHTING_CONFIG.COLORS.STATUS.SUCCESS - if (status < 400) return HIGHLIGHTING_CONFIG.COLORS.STATUS.REDIRECT - if (status < 500) return HIGHLIGHTING_CONFIG.COLORS.STATUS.CLIENT_ERROR - return HIGHLIGHTING_CONFIG.COLORS.STATUS.SERVER_ERROR - } - - const getProtocolColor = (protocol?: string) => { - if (!protocol) return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.UNKNOWN - if (protocol === 'http/1.1') return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.HTTP1_1 - if (protocol === 'h2') return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.H2 - if (protocol === 'h3') return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.H3 - return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.UNKNOWN - } - - const getPriorityColor = (priority?: string) => { - if (!priority) return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.UNKNOWN - if (priority === 'VeryHigh') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.VERY_HIGH - if (priority === 'High') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.HIGH - if (priority === 'Medium') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.MEDIUM - if (priority === 'Low') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.LOW - if (priority === 'VeryLow') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.VERY_LOW - return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.UNKNOWN - } - - const getQueueAnalysisIcon = (analysis?: QueueAnalysis) => { - if (!analysis) return '' - switch (analysis.reason) { - case 'connection_limit': return 'ðŸšŦ' - case 'priority_queue': return 'âģ' - case 'resource_contention': return '⚠ïļ' - default: return '❓' - } - } - - const getSizeColor = (bytes?: number) => { - if (!bytes) return HIGHLIGHTING_CONFIG.COLORS.SIZE.DEFAULT - - const sizeKB = bytes / 1024 - - if (sizeKB >= HIGHLIGHTING_CONFIG.SIZE_THRESHOLDS.LARGE) { - return HIGHLIGHTING_CONFIG.COLORS.SIZE.LARGE - } else if (sizeKB >= HIGHLIGHTING_CONFIG.SIZE_THRESHOLDS.MEDIUM) { - return HIGHLIGHTING_CONFIG.COLORS.SIZE.MEDIUM - } else if (sizeKB <= HIGHLIGHTING_CONFIG.SIZE_THRESHOLDS.SMALL) { - return HIGHLIGHTING_CONFIG.COLORS.SIZE.SMALL - } - - return HIGHLIGHTING_CONFIG.COLORS.SIZE.DEFAULT - } - - const getDurationColor = (microseconds?: number) => { - if (!microseconds) return HIGHLIGHTING_CONFIG.COLORS.DURATION.DEFAULT - - const durationMs = microseconds / 1000 - - if (durationMs > HIGHLIGHTING_CONFIG.DURATION_THRESHOLDS.SLOW) { - return HIGHLIGHTING_CONFIG.COLORS.DURATION.SLOW - } else if (durationMs >= HIGHLIGHTING_CONFIG.DURATION_THRESHOLDS.MEDIUM) { - return HIGHLIGHTING_CONFIG.COLORS.DURATION.MEDIUM - } else if (durationMs < HIGHLIGHTING_CONFIG.DURATION_THRESHOLDS.FAST) { - return HIGHLIGHTING_CONFIG.COLORS.DURATION.FAST - } - - return HIGHLIGHTING_CONFIG.COLORS.DURATION.DEFAULT - } - - const getServerLatencyColor = (microseconds?: number) => { - if (!microseconds) return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.DEFAULT - - if (microseconds > HIGHLIGHTING_CONFIG.SERVER_LATENCY_THRESHOLDS.SLOW) { - return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.SLOW - } else if (microseconds >= HIGHLIGHTING_CONFIG.SERVER_LATENCY_THRESHOLDS.MEDIUM) { - return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.MEDIUM - } else if (microseconds < HIGHLIGHTING_CONFIG.SERVER_LATENCY_THRESHOLDS.FAST) { - return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.FAST - } - - return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.DEFAULT - } - - const getCDNIcon = (analysis?: CDNAnalysis) => { - if (!analysis) return '' - - const providerIcons = { - cloudflare: '🟠', - akamai: 'ðŸ”ĩ', - aws_cloudfront: 'ðŸŸĄ', - fastly: '⚡', - azure_cdn: 'ðŸŸĶ', - google_cdn: 'ðŸŸĒ', - cdn77: 'ðŸŸĢ', - keycdn: 'ðŸ”ķ', - unknown: 'ðŸ“Ą' - } - - const edgeIcon = analysis.isEdge ? '🌐' : '🏠' - const cacheIcon = analysis.cacheStatus === 'hit' ? '✅' : - analysis.cacheStatus === 'miss' ? '❌' : - analysis.cacheStatus === 'expired' ? '⏰' : '' - - return `${providerIcons[analysis.provider]}${edgeIcon}${cacheIcon}` - } - - const getCDNDisplayName = (provider: CDNAnalysis['provider']) => { - const names = { - cloudflare: 'Cloudflare', - akamai: 'Akamai', - aws_cloudfront: 'CloudFront', - fastly: 'Fastly', - azure_cdn: 'Azure CDN', - google_cdn: 'Google CDN', - cdn77: 'CDN77', - keycdn: 'KeyCDN', - unknown: 'Unknown CDN' - } - return names[provider] - } - - const toggleRowExpansion = (requestId: string) => { - const newExpanded = new Set(expandedRows) - if (newExpanded.has(requestId)) { - newExpanded.delete(requestId) - } else { - newExpanded.add(requestId) - } - setExpandedRows(newExpanded) - } - - const truncateUrl = (url: string, maxLength: number = 60) => { - if (url.length <= maxLength) return url - return url.substring(0, maxLength) + '...' - } - - if (loading) { - return ( -
-
-
Loading HTTP requests...
- -
- ) - } - - if (error) { - return ( -
-
-

Error Loading Trace Data

-

{error}

-
-
- ) - } - - return ( -
-

HTTP Requests & Responses

- - {/* Debug info - remove after investigation */} - {traceData && ( -
- Debug: Total events: {traceData.traceEvents.length.toLocaleString()}, - Screenshots found: {screenshots.length}, - HTTP requests: {httpRequests.length} -
- Categories sample: {Array.from(new Set(traceData.traceEvents.slice(0, 1000).map(e => e.cat))).slice(0, 10).join(', ')} -
- Event names sample: {Array.from(new Set(traceData.traceEvents.slice(0, 1000).map(e => e.name))).slice(0, 10).join(', ')} -
- )} - - {/* Controls */} -
- {/* Resource Type Filter */} -
- - -
- - {/* Protocol Filter */} -
- - -
- - {/* Hostname Filter */} -
- - -
- - {/* Priority Filter */} -
- - -
- - {/* Search */} -
- - { - setSearchTerm(e.target.value) - setCurrentPage(1) - }} - style={{ - padding: '8px 12px', - border: '1px solid #ced4da', - borderRadius: '4px', - fontSize: '14px', - minWidth: '300px' - }} - /> -
- - {/* Queue Analysis Toggle */} -
- - -
- - {/* 3D Viewer Toggle */} - {httpRequests.length > 0 && ( -
- - - -
- )} - - {/* Screenshots Toggle */} - {(screenshots.length > 0 || screenshotsLoading) && ( -
- - - {screenshots.length > 0 && !screenshotsLoading && ( -
- -
- Lower values = more screenshots kept (more sensitive to changes) -
-
- )} -
- )} - - {/* Results count */} -
- {showScreenshots ? - `${timelineEntries.length.toLocaleString()} timeline entries` : - `${filteredRequests.length.toLocaleString()} requests found`} -
-
- - {/* Pagination Controls */} - {totalPages > 1 && ( -
- - - - Page {currentPage} of {totalPages} - - - -
- )} - - {/* 3D Network Visualization Modal */} - {show3DViewer && ( -
-
-
-

- 3D Network Visualization ({filteredRequests.length} requests) -

- -
-
- -
-
-
Legend:
-
⮛→⮜ Gray gradient: 2xx Success (darker=lower, lighter=higher)
-
ðŸŸĄ Yellow: 3xx Redirects
-
🟠 Orange: 4xx Client Errors
-
ðŸ”ī Red: 5xx Server Errors
-
Layout:
-
ðŸ”ĩ Central sphere: Origin
-
🏷ïļ Hostname labels: At 12m radius
-
ðŸ“Ķ Request boxes: Start → end timeline
-
📏 Front face: Request start time
-
📐 Height: 0.1m-5m (content-length)
-
📊 Depth: Request duration
-
📚 Overlapping requests stack vertically
-
🔗 Connection lines to center
-
👁ïļ Labels always face camera
-
-
-
- )} - - {/* 3D Timeline Visualization Modal */} - {showTimelineViewer && ( -
-
-
-

- 3D Timeline Visualization ({filteredRequests.length} requests) -

- -
-
- -
-
-
Legend:
-
⮛→⮜ Gray gradient: 2xx Success (darker=lower, lighter=higher)
-
ðŸŸĄ Yellow: 3xx Redirects
-
🟠 Orange: 4xx Client Errors
-
ðŸ”ī Red: 5xx Server Errors
-
Timeline Layout:
-
ðŸ”ĩ Central sphere: Timeline origin
-
🏷ïļ Hostname labels: At 12m radius
-
ðŸ“Ķ Request boxes: Chronological timeline
-
📏 Distance from center: Start time
-
📐 Height: 0.1m-5m (content-length)
-
📊 Depth: Request duration
-
📚 Overlapping requests stack vertically
-
🔗 Connection lines to center
-
👁ïļ Labels face origin (180° rotated)
-
-
-
- )} - - {/* Requests Table */} -
- - - - - - - - - - - - - - - - - - - - - - {(showScreenshots ? paginatedTimelineEntries : paginatedRequests.map(req => ({ type: 'request' as const, timestamp: req.timing.start, data: req }))).map((entry, entryIndex) => { - if (entry.type === 'screenshot') { - const screenshot = entry.data as ScreenshotEvent - const firstRequestTime = httpRequests.length > 0 ? httpRequests[0].timing.start : 0 - const timeOffset = screenshot.timestamp - firstRequestTime - - return ( - - - - ) - } - - const request = entry.data as HTTPRequest - const isExpanded = expandedRows.has(request.requestId) - - return ( - <> - toggleRowExpansion(request.requestId)} - > - - - - - - - - - - - - - - - - - - {/* Expanded Row Details */} - {isExpanded && ( - - - - )} - - ) - })} - -
ExpandMethodStatusTypePriorityStart TimeQueue TimeServer LatencyURLDurationSizeContent-LengthProtocolCDNCache
-
-
- ðŸ“ļ Screenshot -
-
- Time: {formatDuration(timeOffset)} -
- {`Screenshot { - // Open screenshot in new window for full size viewing - const newWindow = window.open('', '_blank') - if (newWindow) { - newWindow.document.write(` - - Screenshot at ${formatDuration(timeOffset)} - - Full size screenshot - - - `) - } - }} - /> -
- Click to view full size -
-
-
- {isExpanded ? '−' : '+'} - - {request.method} - - {request.statusCode || '-'} - - {request.resourceType} - - {request.priority || '-'} - - {formatDuration(request.timing.startOffset)} - HIGHLIGHTING_CONFIG.QUEUE_TIME.HIGH_THRESHOLD ? HIGHLIGHTING_CONFIG.COLORS.STATUS.SERVER_ERROR : '#495057', - fontWeight: request.timing.queueTime && request.timing.queueTime > HIGHLIGHTING_CONFIG.QUEUE_TIME.HIGH_THRESHOLD ? 'bold' : 'normal' - }}> -
- {formatDuration(request.timing.queueTime)} - {showQueueAnalysis && request.queueAnalysis && ( - - {getQueueAnalysisIcon(request.queueAnalysis)} - - )} -
-
- {formatDuration(request.timing.serverLatency)} - - - {truncateUrl(request.url)} - - - {formatDuration(request.timing.duration)} - - {formatSize(request.encodedDataLength)} - - {request.contentLength ? formatSize(request.contentLength) : '-'} - - {request.protocol || '-'} - - {request.cdnAnalysis ? getCDNIcon(request.cdnAnalysis) : '-'} - - {request.fromCache ? 'ðŸ’ū' : request.connectionReused ? '🔄' : '🌐'} -
-
- - {/* Request Details */} -
-

Request Details

-
-
Request ID: {request.requestId}
-
Method: {request.method}
-
Priority: {request.priority}
-
MIME Type: {request.mimeType || '-'}
-
Content-Length: {request.contentLength ? formatSize(request.contentLength) : '-'}
-
From Cache: {request.fromCache ? 'Yes' : 'No'}
-
Connection Reused: {request.connectionReused ? 'Yes' : 'No'}
-
-
- - {/* Network Timing */} -
-

Network Timing

-
-
Start Time: {formatDuration(request.timing.startOffset)}
-
- Queue Time: {formatDuration(request.timing.queueTime)} - {request.queueAnalysis && ( - - {getQueueAnalysisIcon(request.queueAnalysis)} - - )} -
-
Server Latency: {formatDuration(request.timing.serverLatency)}
-
Total Duration: {formatDuration(request.timing.duration)}
-
Network Duration: {formatDuration(request.timing.networkDuration)}
- {request.timing.dnsStart !== -1 && request.timing.dnsEnd !== -1 && ( -
DNS: {formatDuration((request.timing.dnsEnd || 0) - (request.timing.dnsStart || 0))}
- )} - {request.timing.connectStart !== -1 && request.timing.connectEnd !== -1 && ( -
Connect: {formatDuration((request.timing.connectEnd || 0) - (request.timing.connectStart || 0))}
- )} - {request.timing.sslStart !== -1 && request.timing.sslEnd !== -1 && ( -
SSL: {formatDuration((request.timing.sslEnd || 0) - (request.timing.sslStart || 0))}
- )} - {request.timing.sendStart !== -1 && request.timing.sendEnd !== -1 && ( -
Send: {formatDuration((request.timing.sendEnd || 0) - (request.timing.sendStart || 0))}
- )} - {request.timing.receiveHeadersStart !== -1 && request.timing.receiveHeadersEnd !== -1 && ( -
Receive Headers: {formatDuration((request.timing.receiveHeadersEnd || 0) - (request.timing.receiveHeadersStart || 0))}
- )} -
-
- - {/* Queue Analysis */} - {request.queueAnalysis && request.queueAnalysis.reason !== 'unknown' && ( -
-

- Queue Analysis {getQueueAnalysisIcon(request.queueAnalysis)} -

-
-
Reason: {request.queueAnalysis.description}
-
Concurrent Requests: {request.queueAnalysis.concurrentRequests}
- {request.queueAnalysis.relatedRequests && request.queueAnalysis.relatedRequests.length > 0 && ( -
- Related Request IDs:{' '} - - {request.queueAnalysis.relatedRequests.join(', ')} - {request.queueAnalysis.concurrentRequests > request.queueAnalysis.relatedRequests.length && - ` (+${request.queueAnalysis.concurrentRequests - request.queueAnalysis.relatedRequests.length} more)`} - -
- )} -
-
- )} - - {/* CDN Analysis */} - {request.cdnAnalysis && request.cdnAnalysis.provider !== 'unknown' && ( -
-

- CDN Analysis {getCDNIcon(request.cdnAnalysis)} -

-
-
Provider: {getCDNDisplayName(request.cdnAnalysis.provider)}
-
Source: {request.cdnAnalysis.isEdge ? 'Edge Server' : 'Origin Server'}
- {request.cdnAnalysis.cacheStatus && request.cdnAnalysis.cacheStatus !== 'unknown' && ( -
Cache Status: {request.cdnAnalysis.cacheStatus.toUpperCase()}
- )} - {request.cdnAnalysis.edgeLocation && ( -
Edge Location: {request.cdnAnalysis.edgeLocation}
- )} -
Confidence: {(request.cdnAnalysis.confidence * 100).toFixed(0)}%
-
Detection Method: {request.cdnAnalysis.detectionMethod}
- - {/* Debug info for canadiantire.ca requests */} - {request.hostname.includes('canadiantire.ca') && ( -
-
- Debug - CDN Detection Analysis: -
-
- Current Detection: {request.cdnAnalysis?.provider} - (confidence: {((request.cdnAnalysis?.confidence || 0) * 100).toFixed(0)}%) -
-
- All CDN-Related Headers: -
- {request.responseHeaders && request.responseHeaders.map((header, idx) => { - const headerName = header.name.toLowerCase() - // Show all potentially CDN-related headers - if (headerName.includes('akamai') || - headerName.includes('fastly') || - headerName.includes('server') || - headerName.includes('via') || - headerName.includes('x-cache') || - headerName.includes('x-serial') || - headerName.includes('x-served-by') || - headerName.includes('cf-') || - headerName.includes('x-amz-cf') || - headerName.includes('azure') || - headerName.includes('x-goog') || - headerName.includes('cdn') || - headerName.includes('edge') || - headerName.includes('cache')) { - const isAkamaiIndicator = headerName.includes('akamai') || - headerName.includes('x-serial') || - (headerName === 'x-cache' && header.value.includes('tcp_')) || - (headerName === 'x-served-by' && header.value.includes('cache-')) - return ( -
- - {header.name}: - {header.value} - {isAkamaiIndicator && ( - - ← AKAMAI - - )} -
- ) - } - return null - })} -
- )} -
-
- )} - - {/* Response Headers */} - {request.responseHeaders && request.responseHeaders.length > 0 && ( -
-

- Response Headers ({request.responseHeaders.length}) -

-
- {request.responseHeaders.map((header, index) => ( -
-
- {header.name}: -
-
- {header.value} -
-
- ))} -
-
- )} - -
-
-
- - {(showScreenshots ? paginatedTimelineEntries.length === 0 : paginatedRequests.length === 0) && ( -
- {showScreenshots ? - 'No timeline entries found matching the current filters' : - 'No HTTP requests found matching the current filters'} -
- )} -
- ) -} \ No newline at end of file diff --git a/src/components/RequestDebugger.tsx b/src/components/RequestDebugger.tsx new file mode 100644 index 0000000..4744c8d --- /dev/null +++ b/src/components/RequestDebugger.tsx @@ -0,0 +1,811 @@ +import { useState, useMemo } from 'react' +import type { TraceEvent } from '../../types/trace' + +interface RequestDebuggerProps { + traceEvents: TraceEvent[] +} + +interface HTTPRequestEvents { + sendRequest?: TraceEvent + receiveResponse?: TraceEvent + receivedData: TraceEvent[] + finishLoading?: TraceEvent + willSendRequest?: TraceEvent + didReceiveResponse?: TraceEvent + didFinishLoading?: TraceEvent + didFailLoading?: TraceEvent + resourceChangedPriority?: TraceEvent + resourceChangePriority?: TraceEvent + resourceMarkAsCached?: TraceEvent + preloadRenderBlockingStatusChange?: TraceEvent + // Navigation events + navigationRequest?: TraceEvent[] + willStartRequest?: TraceEvent[] + willProcessResponse?: TraceEvent[] + didCommitNavigation?: TraceEvent + // URL Loader events + throttlingURLLoader?: TraceEvent[] + urlLoaderStart?: TraceEvent[] + onReceiveResponse?: TraceEvent[] + onRequestComplete?: TraceEvent[] + onReceivedRedirect?: TraceEvent[] + // Connection events + connection?: TraceEvent[] + keepAliveURLLoader?: TraceEvent[] + // V8 and parsing events + parseOnBackground?: TraceEvent[] + compile?: TraceEvent[] + evaluateScript?: TraceEvent[] + other: TraceEvent[] +} + +export default function RequestDebugger({ traceEvents }: RequestDebuggerProps) { + const [selectedRequestId, setSelectedRequestId] = useState('') + const [searchUrl, setSearchUrl] = useState('') + + // Extract all unique request IDs and URLs + const requestData = useMemo(() => { + const requests = new Map() + const urlToRequestId = new Map() + + // First pass: collect all request IDs and basic events (only by requestId matching) + traceEvents.forEach(event => { + const args = event.args as any + const requestId = args?.data?.requestId || args?.requestId + const url = args?.data?.url + + if (requestId) { + // Only track requests that have requestId (ignore URL-only matching) + if (url && !urlToRequestId.has(url)) { + urlToRequestId.set(url, []) + } + if (url && !urlToRequestId.get(url)!.includes(requestId)) { + urlToRequestId.get(url)!.push(requestId) + } + + if (!requests.has(requestId)) { + requests.set(requestId, { + id: requestId, + url: url || 'No URL', + events: { + receivedData: [], + navigationRequest: [], + willStartRequest: [], + willProcessResponse: [], + throttlingURLLoader: [], + urlLoaderStart: [], + onReceiveResponse: [], + onRequestComplete: [], + onReceivedRedirect: [], + connection: [], + keepAliveURLLoader: [], + parseOnBackground: [], + compile: [], + evaluateScript: [], + other: [] + } + }) + } + + const request = requests.get(requestId)! + + switch (event.name) { + case 'ResourceSendRequest': + request.events.sendRequest = event + break + case 'ResourceReceiveResponse': + request.events.receiveResponse = event + break + case 'ResourceReceivedData': + request.events.receivedData.push(event) + break + case 'ResourceFinish': + case 'ResourceFinishLoading': + request.events.finishLoading = event + break + case 'ResourceWillSendRequest': + request.events.willSendRequest = event + break + case 'ResourceDidReceiveResponse': + request.events.didReceiveResponse = event + break + case 'ResourceDidFinishLoading': + request.events.didFinishLoading = event + break + case 'ResourceDidFailLoading': + request.events.didFailLoading = event + break + case 'ResourceChangedPriority': + request.events.resourceChangedPriority = event + break + case 'ResourceChangePriority': + request.events.resourceChangePriority = event + break + case 'ResourceMarkAsCached': + request.events.resourceMarkAsCached = event + break + case 'PreloadRenderBlockingStatusChange': + request.events.preloadRenderBlockingStatusChange = event + break + // Navigation events + case 'NavigationRequest': + request.events.navigationRequest = request.events.navigationRequest || [] + request.events.navigationRequest.push(event) + break + case 'WillStartRequest': + request.events.willStartRequest = request.events.willStartRequest || [] + request.events.willStartRequest.push(event) + break + case 'WillProcessResponse': + request.events.willProcessResponse = request.events.willProcessResponse || [] + request.events.willProcessResponse.push(event) + break + case 'DidCommitNavigation': + request.events.didCommitNavigation = event + break + // URL Loader events + case 'ThrottlingURLLoader': + request.events.throttlingURLLoader = request.events.throttlingURLLoader || [] + request.events.throttlingURLLoader.push(event) + break + case 'OnReceiveResponse': + request.events.onReceiveResponse = request.events.onReceiveResponse || [] + request.events.onReceiveResponse.push(event) + break + case 'OnRequestComplete': + request.events.onRequestComplete = request.events.onRequestComplete || [] + request.events.onRequestComplete.push(event) + break + case 'OnReceivedRedirect': + request.events.onReceivedRedirect = request.events.onReceivedRedirect || [] + request.events.onReceivedRedirect.push(event) + break + // Connection events + case 'Connection': + request.events.connection = request.events.connection || [] + request.events.connection.push(event) + break + case 'KeepAliveURLLoader': + request.events.keepAliveURLLoader = request.events.keepAliveURLLoader || [] + request.events.keepAliveURLLoader.push(event) + break + case 'v8.parseOnBackground': + request.events.parseOnBackground.push(event) + break + case 'v8.compile': + request.events.compile.push(event) + break + case 'EvaluateScript': + request.events.evaluateScript.push(event) + break + default: + if (event.name.startsWith('Resource') || + event.cat.includes('devtools.timeline') || + event.cat.includes('loading') || + event.cat.includes('v8')) { + request.events.other.push(event) + } + } + } + }) + + // Second pass: find related events ONLY by requestId matching (no URL matching) + traceEvents.forEach(event => { + const args = event.args as any + const eventRequestId = args?.data?.requestId || args?.requestId + + // Only match events that have the exact same requestId + if (eventRequestId && requests.has(eventRequestId)) { + const request = requests.get(eventRequestId)! + if (request) { + switch (event.name) { + case 'v8.parseOnBackground': + if (!request.events.parseOnBackground.some(e => e.ts === event.ts)) { + request.events.parseOnBackground.push(event) + } + break + case 'v8.compile': + if (!request.events.compile.some(e => e.ts === event.ts)) { + request.events.compile.push(event) + } + break + case 'EvaluateScript': + if (!request.events.evaluateScript.some(e => e.ts === event.ts)) { + request.events.evaluateScript.push(event) + } + break + // Add additional network events that might have requestId but not URL + case 'NavigationRequest': + if (!request.events.navigationRequest?.some(e => e.ts === event.ts)) { + request.events.navigationRequest = request.events.navigationRequest || [] + request.events.navigationRequest.push(event) + } + break + case 'WillStartRequest': + if (!request.events.willStartRequest?.some(e => e.ts === event.ts)) { + request.events.willStartRequest = request.events.willStartRequest || [] + request.events.willStartRequest.push(event) + } + break + case 'WillProcessResponse': + if (!request.events.willProcessResponse?.some(e => e.ts === event.ts)) { + request.events.willProcessResponse = request.events.willProcessResponse || [] + request.events.willProcessResponse.push(event) + } + break + case 'ThrottlingURLLoader': + if (!request.events.throttlingURLLoader?.some(e => e.ts === event.ts)) { + request.events.throttlingURLLoader = request.events.throttlingURLLoader || [] + request.events.throttlingURLLoader.push(event) + } + break + case 'OnReceiveResponse': + if (!request.events.onReceiveResponse?.some(e => e.ts === event.ts)) { + request.events.onReceiveResponse = request.events.onReceiveResponse || [] + request.events.onReceiveResponse.push(event) + } + break + case 'OnRequestComplete': + if (!request.events.onRequestComplete?.some(e => e.ts === event.ts)) { + request.events.onRequestComplete = request.events.onRequestComplete || [] + request.events.onRequestComplete.push(event) + } + break + case 'Connection': + if (!request.events.connection?.some(e => e.ts === event.ts)) { + request.events.connection = request.events.connection || [] + request.events.connection.push(event) + } + break + default: + if ((event.name.includes('URLLoader') || + event.name.includes('BackgroundProcessor') || + event.name.includes('ResourceRequest') || + event.name.includes('MojoURLLoader') || + event.name.includes('KeepAlive') || + event.cat.includes('loading') || + event.cat.includes('devtools.timeline') || + event.cat.includes('v8')) && + !request.events.other.some(e => e.ts === event.ts)) { + request.events.other.push(event) + } + } + } + } + }) + + // Sort events by timestamp + requests.forEach(request => { + request.events.receivedData.sort((a, b) => a.ts - b.ts) + request.events.navigationRequest?.sort((a, b) => a.ts - b.ts) + request.events.willStartRequest?.sort((a, b) => a.ts - b.ts) + request.events.willProcessResponse?.sort((a, b) => a.ts - b.ts) + request.events.throttlingURLLoader?.sort((a, b) => a.ts - b.ts) + request.events.urlLoaderStart?.sort((a, b) => a.ts - b.ts) + request.events.onReceiveResponse?.sort((a, b) => a.ts - b.ts) + request.events.onRequestComplete?.sort((a, b) => a.ts - b.ts) + request.events.onReceivedRedirect?.sort((a, b) => a.ts - b.ts) + request.events.connection?.sort((a, b) => a.ts - b.ts) + request.events.keepAliveURLLoader?.sort((a, b) => a.ts - b.ts) + request.events.parseOnBackground.sort((a, b) => a.ts - b.ts) + request.events.compile.sort((a, b) => a.ts - b.ts) + request.events.evaluateScript.sort((a, b) => a.ts - b.ts) + request.events.other.sort((a, b) => a.ts - b.ts) + }) + + return Array.from(requests.values()) + }, [traceEvents]) + + // Filter requests based on search + const filteredRequests = useMemo(() => { + if (!searchUrl) return requestData + return requestData.filter(req => + req.url.toLowerCase().includes(searchUrl.toLowerCase()) || + req.id.toLowerCase().includes(searchUrl.toLowerCase()) + ) + }, [requestData, searchUrl]) + + const selectedRequest = requestData.find(req => req.id === selectedRequestId) + + const formatTimestamp = (ts: number, baseTimestamp?: number) => { + const fullTimestamp = ts.toLocaleString() + ' Ξs' + if (baseTimestamp && ts !== baseTimestamp) { + const offset = ts - baseTimestamp + const offsetMs = offset / 1000 + const offsetSign = offset >= 0 ? '+' : '' + return `${fullTimestamp} (${offsetSign}${offset.toLocaleString()} Ξs = ${offsetSign}${offsetMs.toFixed(3)} ms)` + } + return fullTimestamp + ' (baseline)' + } + + const formatDuration = (startTs: number, endTs: number) => { + const durationUs = endTs - startTs + const durationMs = durationUs / 1000 + return `${durationUs.toLocaleString()} Ξs (${durationMs.toFixed(3)} ms)` + } + + const formatTiming = (timing: any) => { + if (!timing) return null + + return ( +
+ Network Timing (from ResourceReceiveResponse): +
+
requestTime: {timing.requestTime} seconds
+
dnsStart: {timing.dnsStart} ms
+
dnsEnd: {timing.dnsEnd} ms
+
connectStart: {timing.connectStart} ms
+
connectEnd: {timing.connectEnd} ms
+
sslStart: {timing.sslStart} ms
+
sslEnd: {timing.sslEnd} ms
+
sendStart: {timing.sendStart} ms
+
sendEnd: {timing.sendEnd} ms
+
receiveHeadersStart: {timing.receiveHeadersStart} ms
+
receiveHeadersEnd: {timing.receiveHeadersEnd} ms
+
+
+ ) + } + + const calculateDurations = (events: HTTPRequestEvents) => { + if (!events.sendRequest || !events.receiveResponse) return null + + const args = events.receiveResponse.args as any + const timing = args?.data?.timing + const finishTime = events.finishLoading ? (events.finishLoading.args as any)?.data?.finishTime : null + const lastDataEvent = events.receivedData[events.receivedData.length - 1] + + return ( +
+ Duration Calculations: + + {timing && ( +
+
Chrome DevTools Method (sendStart to receiveHeadersEnd):
+
+ {timing.receiveHeadersEnd - timing.sendStart} ms +
+
+ )} + + {timing && timing.connectStart >= 0 && lastDataEvent && ( +
+
Our Method - New Connection (connectStart to last data):
+
+ {(lastDataEvent.ts - (timing.requestTime * 1000000 + timing.connectStart * 1000)) / 1000} ms +
+
+ )} + + {timing && timing.connectStart < 0 && lastDataEvent && ( +
+
Our Method - Reused Connection (sendStart to last data):
+
+ {(lastDataEvent.ts - (timing.requestTime * 1000000 + timing.sendStart * 1000)) / 1000} ms +
+
+ )} + + {timing && ( +
+
Fallback Method (receiveHeadersEnd):
+
+ {timing.receiveHeadersEnd} ms +
+
+ )} + +
+ Trace Event Timestamps: +
Send Request: {formatTimestamp(events.sendRequest.ts)}
+
Receive Response: {formatTimestamp(events.receiveResponse.ts)}
+ {lastDataEvent &&
Last Data Chunk: {formatTimestamp(lastDataEvent.ts)}
} + {events.finishLoading &&
Finish Loading: {formatTimestamp(events.finishLoading.ts)}
} +
+
+ ) + } + + return ( +
+

Request Timeline Debugger

+ + {/* Search Controls */} +
+
+ + setSearchUrl(e.target.value)} + style={{ + padding: '8px 12px', + border: '1px solid #ced4da', + borderRadius: '4px', + fontSize: '14px', + width: '400px' + }} + /> +
+ +
+ + +
+
+ + {selectedRequest && ( +
+

Request Details: {selectedRequest.id}

+
+ URL: {selectedRequest.url} +
+ + {/* Duration Calculations */} + {calculateDurations(selectedRequest.events)} + + {/* Event Details */} +
+

All Events for this Request:

+ + {(() => { + const baseTimestamp = selectedRequest.events.sendRequest?.ts + return ( + <> + + {selectedRequest.events.sendRequest && ( +
+ ResourceSendRequest - {formatTimestamp(selectedRequest.events.sendRequest.ts, baseTimestamp)} +
+                      {JSON.stringify(selectedRequest.events.sendRequest.args, null, 2)}
+                    
+
+ )} + + {selectedRequest.events.receiveResponse && ( +
+ ResourceReceiveResponse - {formatTimestamp(selectedRequest.events.receiveResponse.ts, baseTimestamp)} + {formatTiming((selectedRequest.events.receiveResponse.args as any)?.data?.timing)} +
+                      {JSON.stringify(selectedRequest.events.receiveResponse.args, null, 2)}
+                    
+
+ )} + + {selectedRequest.events.receivedData.map((event, index) => ( +
+ ResourceReceivedData #{index + 1} - {formatTimestamp(event.ts, baseTimestamp)} + {index === selectedRequest.events.receivedData.length - 1 && ( + ← LAST DATA CHUNK + )} +
+                      {JSON.stringify(event.args, null, 2)}
+                    
+
+ ))} + + {selectedRequest.events.finishLoading && ( +
+ ResourceFinish - {formatTimestamp(selectedRequest.events.finishLoading.ts, baseTimestamp)} +
+                      {JSON.stringify(selectedRequest.events.finishLoading.args, null, 2)}
+                    
+
+ )} + + {/* Additional Event Types */} + {selectedRequest.events.parseOnBackground.length > 0 && ( +
+
V8 Parse Events:
+ {selectedRequest.events.parseOnBackground.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} + {(event as any).dur && Duration: {((event as any).dur / 1000).toFixed(2)}ms} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {selectedRequest.events.compile.length > 0 && ( +
+
V8 Compile Events:
+ {selectedRequest.events.compile.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} + {(event as any).dur && Duration: {((event as any).dur / 1000).toFixed(2)}ms} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {selectedRequest.events.evaluateScript.length > 0 && ( +
+
Script Evaluation Events:
+ {selectedRequest.events.evaluateScript.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} + {(event as any).dur && Duration: {((event as any).dur / 1000).toFixed(2)}ms} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {/* Additional Network Events */} + {selectedRequest.events.willSendRequest && ( +
+ ResourceWillSendRequest - {formatTimestamp(selectedRequest.events.willSendRequest.ts, baseTimestamp)} +
+                      {JSON.stringify(selectedRequest.events.willSendRequest.args, null, 2)}
+                    
+
+ )} + + {selectedRequest.events.didReceiveResponse && ( +
+ ResourceDidReceiveResponse - {formatTimestamp(selectedRequest.events.didReceiveResponse.ts, baseTimestamp)} +
+                      {JSON.stringify(selectedRequest.events.didReceiveResponse.args, null, 2)}
+                    
+
+ )} + + {selectedRequest.events.didFinishLoading && ( +
+ ResourceDidFinishLoading - {formatTimestamp(selectedRequest.events.didFinishLoading.ts, baseTimestamp)} +
+                      {JSON.stringify(selectedRequest.events.didFinishLoading.args, null, 2)}
+                    
+
+ )} + + {selectedRequest.events.resourceChangedPriority && ( +
+ ResourceChangedPriority - {formatTimestamp(selectedRequest.events.resourceChangedPriority.ts, baseTimestamp)} +
+                      {JSON.stringify(selectedRequest.events.resourceChangedPriority.args, null, 2)}
+                    
+
+ )} + + {selectedRequest.events.resourceChangePriority && ( +
+ ResourceChangePriority - {formatTimestamp(selectedRequest.events.resourceChangePriority.ts, baseTimestamp)} +
+                      {JSON.stringify(selectedRequest.events.resourceChangePriority.args, null, 2)}
+                    
+
+ )} + + {selectedRequest.events.resourceMarkAsCached && ( +
+ ResourceMarkAsCached - {formatTimestamp(selectedRequest.events.resourceMarkAsCached.ts, baseTimestamp)} +
+                      {JSON.stringify(selectedRequest.events.resourceMarkAsCached.args, null, 2)}
+                    
+
+ )} + + {selectedRequest.events.preloadRenderBlockingStatusChange && ( +
+ PreloadRenderBlockingStatusChange - {formatTimestamp(selectedRequest.events.preloadRenderBlockingStatusChange.ts, baseTimestamp)} +
+                      {JSON.stringify(selectedRequest.events.preloadRenderBlockingStatusChange.args, null, 2)}
+                    
+
+ )} + + {/* Navigation Events */} + {selectedRequest.events.navigationRequest && selectedRequest.events.navigationRequest.length > 0 && ( +
+
Navigation Request Events:
+ {selectedRequest.events.navigationRequest.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} + {(event as any).dur && Duration: {((event as any).dur / 1000).toFixed(2)}ms} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {selectedRequest.events.willStartRequest && selectedRequest.events.willStartRequest.length > 0 && ( +
+
Will Start Request Events:
+ {selectedRequest.events.willStartRequest.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {selectedRequest.events.willProcessResponse && selectedRequest.events.willProcessResponse.length > 0 && ( +
+
Will Process Response Events:
+ {selectedRequest.events.willProcessResponse.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {selectedRequest.events.didCommitNavigation && ( +
+ DidCommitNavigation - {formatTimestamp(selectedRequest.events.didCommitNavigation.ts, baseTimestamp)} +
+                      {JSON.stringify(selectedRequest.events.didCommitNavigation.args, null, 2)}
+                    
+
+ )} + + {/* URL Loader Events */} + {selectedRequest.events.throttlingURLLoader && selectedRequest.events.throttlingURLLoader.length > 0 && ( +
+
Throttling URL Loader Events ({selectedRequest.events.throttlingURLLoader.length}):
+ {selectedRequest.events.throttlingURLLoader.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} + {(event as any).dur && Duration: {((event as any).dur / 1000).toFixed(2)}ms} +
Category: {event.cat}
+
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {selectedRequest.events.onReceiveResponse && selectedRequest.events.onReceiveResponse.length > 0 && ( +
+
URL Loader OnReceiveResponse Events:
+ {selectedRequest.events.onReceiveResponse.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {selectedRequest.events.onRequestComplete && selectedRequest.events.onRequestComplete.length > 0 && ( +
+
URL Loader OnRequestComplete Events:
+ {selectedRequest.events.onRequestComplete.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {selectedRequest.events.onReceivedRedirect && selectedRequest.events.onReceivedRedirect.length > 0 && ( +
+
Redirect Events:
+ {selectedRequest.events.onReceivedRedirect.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {/* Connection Events */} + {selectedRequest.events.connection && selectedRequest.events.connection.length > 0 && ( +
+
Connection Events:
+ {selectedRequest.events.connection.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} + {(event as any).dur && Duration: {((event as any).dur / 1000).toFixed(2)}ms} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {selectedRequest.events.keepAliveURLLoader && selectedRequest.events.keepAliveURLLoader.length > 0 && ( +
+
Keep-Alive URL Loader Events:
+ {selectedRequest.events.keepAliveURLLoader.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} +
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + {selectedRequest.events.other.length > 0 && ( +
+
Other Related Events ({selectedRequest.events.other.length}):
+ {selectedRequest.events.other.map((event, index) => ( +
+ {event.name} - {formatTimestamp(event.ts, baseTimestamp)} + {(event as any).dur && Duration: {((event as any).dur / 1000).toFixed(2)}ms} +
Category: {event.cat}
+
+                          {JSON.stringify(event.args, null, 2)}
+                        
+
+ ))} +
+ )} + + + ) + })()} +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/components/httprequestviewer/HTTPRequestLoading.module.css b/src/components/httprequestviewer/HTTPRequestLoading.module.css new file mode 100644 index 0000000..4dcc2ad --- /dev/null +++ b/src/components/httprequestviewer/HTTPRequestLoading.module.css @@ -0,0 +1,26 @@ +.header-div { + display: flex; + flexDirection: column; + justifyContent: center; + alignItems: center; + minHeight: 400px; + fontSize: 18px; + color: #6c757d; + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } +} +.header-content { + width: 50px; + height: 50px; + border: 4px solid #f3f3f3; + borderTop: 4px solid #007bff; + borderRadius: 50%; + animation: spin 1s linear infinite; + marginBottom: 20px; + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } +} \ No newline at end of file diff --git a/src/components/httprequestviewer/HTTPRequestLoading.tsx b/src/components/httprequestviewer/HTTPRequestLoading.tsx new file mode 100644 index 0000000..9b61b9f --- /dev/null +++ b/src/components/httprequestviewer/HTTPRequestLoading.tsx @@ -0,0 +1,13 @@ +import styles from './HTTPRequestLoading.module.css'; +export default function HTTPRequestLoading() { + return (<> +
+
+
Loading HTTP requests...
+
+
+
+
Loading HTTP requests...
+
+ ); +} \ No newline at end of file diff --git a/src/components/httprequestviewer/HTTPRequestViewer.module.css b/src/components/httprequestviewer/HTTPRequestViewer.module.css new file mode 100644 index 0000000..b0f3d6c --- /dev/null +++ b/src/components/httprequestviewer/HTTPRequestViewer.module.css @@ -0,0 +1,705 @@ +/* CSS Custom Properties */ +:root { + --color-primary: #007bff; + --color-success: #28a745; + --color-text: #495057; + --color-text-muted: #6c757d; + --color-text-light: #666; + --color-error: #721c24; + --color-error-bg: #f8d7da; + --color-error-border: #f5c6cb; + --color-warning-bg: #fff3cd; + --color-warning-border: #ffeaa7; + --color-info-bg: #f0f8ff; + --color-info-hover: #e3f2fd; + --color-info-bg-dark: #d1ecf1; + --color-info-text: #0c5460; + --color-border: #dee2e6; + --color-border-light: #f1f3f4; + --color-bg-light: #f8f9fa; + --color-bg-white: white; + --color-bg-disabled: #e9ecef; + + --font-family-base: system-ui, sans-serif; + --font-family-mono: monospace; + + --font-size-xs: 9px; + --font-size-sm: 10px; + --font-size-base: 11px; + --font-size-md: 12px; + --font-size-lg: 14px; + --font-size-xl: 16px; + --font-size-xxl: 18px; + + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 15px; + --spacing-lg: 20px; + + --border-radius: 4px; + --border-radius-lg: 8px; + --border-radius-xl: 12px; +} + +/* Combined Base Classes (now expanded inline) */ +.tableCellBase { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; +} + +.tableCellCenter { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + text-align: center; +} + +.tableCellRight { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + text-align: right; +} + +.tableCellMonoBold { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + font-family: var(--font-family-mono); + font-weight: bold; +} + +.tableCellCenterMonoBold { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + text-align: center; + font-family: var(--font-family-mono); + font-weight: bold; +} + +.responseTimeCell { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + text-align: right; + font-family: var(--font-family-mono); + font-weight: bold; + border-radius: var(--border-radius); +} + +.btnBase { + padding: var(--spacing-sm); + border-radius: var(--border-radius); + cursor: pointer; + border: 1px solid var(--color-border); + font-size: var(--font-size-lg); +} + +/* Main container */ +.container { + padding: var(--spacing-lg); + font-family: var(--font-family-base); +} + +/* Error states */ +.errorContainer { + padding: var(--spacing-lg); + text-align: center; +} + +.errorMessage { + background: var(--color-error-bg); + color: var(--color-error); + padding: var(--spacing-md); + border-radius: var(--border-radius-lg); + border: 1px solid var(--color-error-border); +} + +.errorMessage h3, +.errorMessage p { + margin: 0; +} + +.errorMessage h3 { + margin-bottom: 10px; +} + +/* Pagination controls */ +.paginationControls { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + margin-bottom: var(--spacing-lg); +} + +.paginationButton { + padding: var(--spacing-sm) 16px; + border-radius: var(--border-radius); + cursor: pointer; + border: 1px solid var(--color-border); + font-size: var(--font-size-lg); + background: var(--color-bg-white); +} + +.paginationButton:disabled { + background: var(--color-bg-disabled); + cursor: not-allowed; +} + +.paginationInfo { + margin: 0 var(--spacing-md); + font-size: var(--font-size-lg); +} + +/* Modal components */ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modalContainer { + width: 90vw; + height: 90vh; + background: var(--color-bg-white); + border-radius: var(--border-radius-xl); + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + border-radius: var(--border-radius-xl) var(--border-radius-xl) 0 0; +} + +.modalTitle { + margin: 0; + color: var(--color-text); + font-size: var(--font-size-xxl); +} + +.modalCloseButton { + padding: var(--spacing-sm); + border-radius: 6px; + cursor: pointer; + border: 1px solid var(--color-text-muted); + font-size: var(--font-size-lg); + background: transparent; + color: var(--color-text-muted); +} + +.modalCloseButton:hover { + background: var(--color-bg-light); +} + +.modalContent { + flex: 1; + padding: 10px; +} + +.modalLegend { + padding: var(--spacing-md) var(--spacing-lg); + font-size: var(--font-size-md); + color: var(--color-text-muted); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; + border-top: 1px solid var(--color-border); + border-radius: 0 0 var(--border-radius-xl) var(--border-radius-xl); + background-color: var(--color-bg-light); +} + +.modalLegend div { + margin: 2px 0; +} + +.modalLegend strong { + font-weight: bold; +} + +/* Table components */ +.tableContainer { + background: var(--color-bg-white); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + overflow: hidden; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.tableHeader { + background: var(--color-bg-light); +} + +.tableHeaderCell { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + font-weight: bold; + border-bottom: 1px solid var(--color-border); +} + +.tableHeaderCell.center { + padding: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); + font-size: var(--font-size-md); + font-weight: bold; + text-align: center; +} +.tableHeaderCell.left { + padding: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); + font-size: var(--font-size-md); + font-weight: bold; + text-align: left; +} +.tableHeaderCell.right { + padding: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); + font-size: var(--font-size-md); + font-weight: bold; + text-align: right; +} +.tableHeaderCell.expandColumn { + padding: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); + font-size: var(--font-size-md); + font-weight: bold; + width: 30px; +} + +/* Table rows */ +.tableRow { + border-bottom: 1px solid var(--color-border-light); + cursor: pointer; +} + +.tableRow:hover { + background-color: var(--color-bg-light); +} + +/* Screenshot components */ +.screenshotRow { + background-color: var(--color-info-bg); + border-bottom: 2px solid var(--color-primary); +} + +.screenshotRow:hover { + background-color: var(--color-info-hover); +} + +.screenshotContainer { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-md); +} + +.screenshotLabel { + font-weight: bold; + color: var(--color-primary); + font-size: var(--font-size-lg); + min-width: 120px; +} + +.screenshotTime { + font-family: var(--font-family-mono); + color: var(--color-text); + font-size: var(--font-size-md); +} + +.screenshotImage { + max-width: 200px; + max-height: 150px; + border: 2px solid var(--color-primary); + border-radius: var(--border-radius); + cursor: pointer; + transition: transform 0.2s ease; +} + +.screenshotImage:hover { + transform: scale(1.05); +} + +.screenshotHint { + font-size: var(--font-size-base); + color: var(--color-text-muted); + font-style: italic; +} + +/* Table cell variants */ +.tableCell { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; +} + +.tableCell.center { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + text-align: center; +} + +.tableCell.right { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + text-align: right; +} + +.tableCell.monospace { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + font-family: var(--font-family-mono); +} + +.tableCell.bold { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + font-weight: bold; +} + +.tableCell.gray { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + color: var(--color-text-muted); +} + +.tableCell.expandCell { + padding: var(--spacing-sm); + font-size: var(--font-size-xl); + vertical-align: middle; + color: var(--color-primary); + font-weight: bold; + user-select: none; +} + +.tableCell.statusCell { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + text-align: center; + font-family: var(--font-family-mono); + font-weight: bold; +} + +.tableCell.methodCell { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + font-family: var(--font-family-mono); + font-weight: bold; +} + +.tableCell.priorityCell { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + text-align: center; + font-family: var(--font-family-mono); + font-weight: bold; +} + +.tableCell.timeCell { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + vertical-align: middle; + text-align: right; + font-family: var(--font-family-mono); + color: var(--color-text); +} + +/* Specialized cells with shared base */ +.totalResponseTimeCell { + padding: var(--spacing-sm); + text-align: right; + font-size: var(--font-size-md); + font-family: var(--font-family-mono); + font-weight: bold; + border-radius: var(--border-radius); + border: 1px solid var(--color-border-light); +} +.durationCell { + padding: var(--spacing-sm); + text-align: right; + font-size: var(--font-size-md); + font-family: var(--font-family-mono); + font-weight: bold; + border-radius: var(--border-radius); +} +.sizeCell { + padding: var(--spacing-sm); + text-align: right; + font-size: var(--font-size-md); + font-family: var(--font-family-mono); + font-weight: bold; + border-radius: var(--border-radius); +} +.serverLatencyCell { + padding: var(--spacing-sm); + text-align: right; + font-size: var(--font-size-md); + font-family: var(--font-family-mono); + font-weight: bold; + border-radius: var(--border-radius); +} + +.protocolCell { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + text-align: center; + font-family: var(--font-family-mono); + font-weight: bold; +} + +.cdnCell, .cacheCell { + padding: var(--spacing-sm); + font-size: var(--font-size-md); + text-align: center; +} +.cdnCell { cursor: help; } +.cdnCell.default { cursor: default; } + +/* Method styling */ +.methodGet { color: var(--color-success); } +.methodOther { color: var(--color-primary); } + +/* URL cell */ +.urlCell { + padding: var(--spacing-sm); + font-size: var(--font-size-base); + vertical-align: middle; + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; +} + +.urlLink { + color: var(--color-primary); + text-decoration: none; +} + +.urlLink:hover { + text-decoration: underline; +} + +/* Queue time components */ +.queueTimeContainer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-xs); +} + +.queueAnalysisIcon { + cursor: help; + font-size: var(--font-size-lg); +} + +/* Connection status - consolidated */ +.connectionCached, +.connectionReused { + color: var(--color-text-light); + font-style: italic; +} + +/* Cache indicators - using ::before for consistency */ +.cacheFromCache::before, +.cacheConnectionReused::before, +.cacheNetwork::before { + margin-right: var(--spacing-xs); +} + +.cacheFromCache::before { content: 'ðŸ’ū'; } +.cacheConnectionReused::before { content: '🔄'; } +.cacheNetwork::before { content: '🌐'; } + +/* Expanded row details */ +.expandedRow { + background: var(--color-bg-light); + border: 1px solid var(--color-border-light); +} + +.expandedContent { + padding: var(--spacing-md); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--spacing-md); +} + +.detailCard { + border-radius: var(--border-radius); + background: var(--color-bg-white); + padding: 10px; + border: 1px solid var(--color-border); +} + +.detailCard.fullWidth { + grid-column: 1 / -1; +} + +.detailCardTitle { + margin: 0 0 var(--spacing-sm) 0; + color: var(--color-text); + font-size: var(--font-size-lg); + font-weight: bold; +} + +.detailList { + font-size: var(--font-size-md); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.detailListItem { + margin: 0; +} + +.detailListItem strong { + font-weight: bold; + margin-right: var(--spacing-sm); +} + +/* Network timing details */ +.timingHighlighted { + font-weight: bold; + border-radius: var(--border-radius); + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid var(--color-border-light); +} + +/* Analysis sections - consolidated */ +.queueAnalysisCard .detailCardTitle, +.cdnAnalysisCard .detailCardTitle { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.relatedRequestIds { + font-family: var(--font-family-mono); + font-size: var(--font-size-base); + color: var(--color-text); + word-break: break-all; +} + +/* Debug section */ +.debugSection { + border-radius: var(--border-radius); + margin-top: var(--spacing-sm); + padding: var(--spacing-sm); + background: var(--color-warning-bg); + border: 1px solid var(--color-warning-border); +} + +.debugTitle { + font-weight: bold; + margin-bottom: var(--spacing-xs); + font-size: var(--font-size-base); +} + +.debugInfo { + margin-bottom: 6px; + font-size: var(--font-size-base); +} + +.debugHeaders { + font-weight: bold; + font-size: var(--font-size-sm); + margin-bottom: 2px; +} + +.headerLine { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + margin-bottom: 1px; + padding: 1px 3px; + border-radius: 2px; +} + +.headerLine.akamaiIndicator { + background-color: var(--color-info-bg-dark); + color: var(--color-info-text); +} + +.headerName { + font-weight: bold; + margin-right: 5px; +} + +.headerName.akamaiIndicator, +.akamaiLabel { + color: var(--color-info-text); + font-weight: bold; +} + +.akamaiLabel { + margin-left: 5px; +} + +/* Response headers section */ +.headersContainer { + font-family: var(--font-family-mono); + max-height: 150px; + overflow-y: auto; + font-size: var(--font-size-base); + background: var(--color-bg-light); + padding: var(--spacing-sm); + border-radius: 3px; +} + +.headerItem { + margin-bottom: 2px; + display: grid; + grid-template-columns: 150px 1fr; + gap: 10px; +} + +.headerItemName { + color: var(--color-primary); + font-weight: bold; +} + +.headerItemValue { + color: var(--color-text); + word-break: break-all; +} + +/* Utility classes */ +.noResults { + text-align: center; + color: var(--color-text-muted); + padding: 40px; + font-size: var(--font-size-xl); +} + +.coloredBackground { + border-radius: var(--border-radius); + padding: var(--spacing-xs) 6px; +} + +.highlighted { + font-weight: bold; +} \ No newline at end of file diff --git a/src/components/httprequestviewer/HTTPRequestViewer.tsx b/src/components/httprequestviewer/HTTPRequestViewer.tsx new file mode 100644 index 0000000..26e68c1 --- /dev/null +++ b/src/components/httprequestviewer/HTTPRequestViewer.tsx @@ -0,0 +1,388 @@ +import { useState, useMemo, useEffect } from 'react' +import { useDatabaseTraceData } from '../../hooks/useDatabaseTraceData' +import BabylonViewer from '../../BabylonViewer' +import BabylonTimelineViewer from '../../BabylonTimelineViewer' +import RequestFilters from './RequestFilters' +import RequestsTable from './RequestsTable' +import styles from './HTTPRequestViewer.module.css' + +// 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 types +import type { HTTPRequest, ScreenshotEvent } from './types/httpRequest' +import HTTPRequestLoading from "./HTTPRequestLoading.tsx"; +import sortRequests from "./lib/sortRequests.ts"; + + +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('all') + const [protocolFilter, setProtocolFilter] = useState('all') + const [hostnameFilter, setHostnameFilter] = useState('all') + const [priorityFilter, setPriorityFilter] = useState('all') + const [showQueueAnalysis, setShowQueueAnalysis] = useState(false) + const [showScreenshots, setShowScreenshots] = useState(false) + const [show3DViewer, setShow3DViewer] = useState(false) + const [showTimelineViewer, setShowTimelineViewer] = useState(false) + const [ssimThreshold, setSsimThreshold] = useState(SSIM_SIMILARITY_THRESHOLD) + const [pendingSSIMThreshold, setPendingSSIMThreshold] = useState(SSIM_SIMILARITY_THRESHOLD) + const [expandedRows, setExpandedRows] = useState>(new Set()) + + const handleSSIMRecalculate = () => { + setSsimThreshold(pendingSSIMThreshold) + } + + const httpRequests = useMemo(() => { + if (!traceData) return [] + const httpRequests = processHTTPRequests(traceData.traceEvents) + const sortedRequests = sortRequests(httpRequests) + const processedRequests = addRequestPostProcessing(sortedRequests, analyzeQueueReason, analyzeCDN) + return processedRequests + }, [traceData]) + + // Extract and process screenshots with SSIM analysis + const [screenshots, setScreenshots] = useState([]) + 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) + } + + + if (loading) { + return ( + + ) + } + + if (error) { + return ( +
+
+

Error Loading Trace Data

+

{error}

+
+
+ ) + } + + return ( +
+

HTTP Requests & Responses

+ {/* Controls */} + + + {/* Pagination Controls */} + {totalPages > 1 && ( +
+ + + + Page {currentPage} of {totalPages} + + + +
+ )} + + {/* 3D Network Visualization Modal */} + {show3DViewer && ( +
+
+
+

+ 3D Network Visualization ({filteredRequests.length} requests) +

+ +
+
+ +
+
+
Legend:
+
⮛→⮜ Gray gradient: 2xx Success (darker=lower, lighter=higher)
+
ðŸŸĄ Yellow: 3xx Redirects
+
🟠 Orange: 4xx Client Errors
+
ðŸ”ī Red: 5xx Server Errors
+
Layout:
+
ðŸ”ĩ Central sphere: Origin
+
🏷ïļ Hostname labels: At 12m radius
+
ðŸ“Ķ Request boxes: Start → end timeline
+
📏 Front face: Request start time
+
📐 Height: 0.1m-5m (content-length)
+
📊 Depth: Request duration
+
📚 Overlapping requests stack vertically
+
🔗 Connection lines to center
+
👁ïļ Labels always face camera
+
+
+
+ )} + + {/* 3D Timeline Visualization Modal */} + {showTimelineViewer && ( +
+
+
+

+ 3D Timeline Visualization ({filteredRequests.length} requests) +

+ +
+
+ +
+
+
Legend:
+
⮛→⮜ Gray gradient: 2xx Success (darker=lower, lighter=higher)
+
ðŸŸĄ Yellow: 3xx Redirects
+
🟠 Orange: 4xx Client Errors
+
ðŸ”ī Red: 5xx Server Errors
+
Timeline Layout:
+
ðŸ”ĩ Central sphere: Timeline origin
+
🏷ïļ Hostname labels: At 12m radius
+
ðŸ“Ķ Request boxes: Chronological timeline
+
📏 Distance from center: Start time
+
📐 Height: 0.1m-5m (content-length)
+
📊 Depth: Request duration
+
📚 Overlapping requests stack vertically
+
🔗 Connection lines to center
+
👁ïļ Labels face origin (180° rotated)
+
+
+
+ )} + + {/* Requests Table */} + +
+ ) +} \ No newline at end of file diff --git a/src/components/httprequestviewer/RequestFilters.module.css b/src/components/httprequestviewer/RequestFilters.module.css new file mode 100644 index 0000000..d9365fe --- /dev/null +++ b/src/components/httprequestviewer/RequestFilters.module.css @@ -0,0 +1,139 @@ +/* Main container */ +.filtersContainer { + background: #f8f9fa; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + display: flex; + flex-wrap: wrap; + gap: 15px; + align-items: center; +} + +/* Filter group container */ +.filterGroup { + display: flex; + flex-direction: column; + gap: 5px; +} + +/* Labels */ +.filterLabel { + font-weight: bold; + font-size: 14px; +} + +/* Base styles for form controls */ +.select { + padding: 8px 12px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 14px; +} + +.input { + padding: 8px 12px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 14px; +} + +/* Specific select widths */ +.resourceTypeSelect { + composes: select; + min-width: 150px; +} + +.protocolSelect { + composes: select; + min-width: 120px; +} + +.hostnameSelect { + composes: select; + min-width: 200px; +} + +.prioritySelect { + composes: select; + min-width: 130px; +} + +.searchInput { + composes: input; + min-width: 300px; +} + +/* Checkbox labels */ +.checkboxLabel { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +} + +.checkbox { + cursor: pointer; +} + +/* Screenshot controls */ +.screenshotLoadingText { + color: #007bff; +} + +.ssimControls { + margin-top: 5px; + font-size: 12px; +} + +.ssimControlsRow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ssimLabel { + display: flex; + align-items: center; + gap: 8px; +} + +.ssimSlider { + width: 100px; +} + +.recalculateButton { + padding: 4px 8px; + font-size: 11px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; +} + +.recalculateButton:hover { + background-color: #0056b3; +} + +.ssimPendingText { + color: #f57c00; + font-size: 10px; + font-style: italic; +} + +.ssimHelpText { + font-size: 10px; + color: #6c757d; + margin-top: 2px; +} + +/* Results counter */ +.resultsCounter { + margin-left: auto; + font-size: 14px; + color: #6c757d; + font-weight: bold; +} \ No newline at end of file diff --git a/src/components/httprequestviewer/RequestFilters.tsx b/src/components/httprequestviewer/RequestFilters.tsx new file mode 100644 index 0000000..e683125 --- /dev/null +++ b/src/components/httprequestviewer/RequestFilters.tsx @@ -0,0 +1,290 @@ +import React from 'react' +import styles from './RequestFilters.module.css' +import type { HTTPRequest, ScreenshotEvent } from './types/httpRequest' + +export interface RequestFiltersProps { + // Data + httpRequests: HTTPRequest[] + screenshots: ScreenshotEvent[] + screenshotsLoading: boolean + resourceTypes: string[] + protocols: string[] + hostnames: string[] + priorities: string[] + filteredRequests: HTTPRequest[] + timelineEntries: Array<{ + type: 'request' | 'screenshot' + timestamp: number + data: HTTPRequest | ScreenshotEvent + }> + + // Filter States + resourceTypeFilter: string + protocolFilter: string + hostnameFilter: string + priorityFilter: string + searchTerm: string + + // Display Options + showQueueAnalysis: boolean + showScreenshots: boolean + show3DViewer: boolean + showTimelineViewer: boolean + + // SSIM Controls + pendingSSIMThreshold: number + ssimThreshold: number + + // Handlers + setResourceTypeFilter: (value: string) => void + setProtocolFilter: (value: string) => void + setHostnameFilter: (value: string) => void + setPriorityFilter: (value: string) => void + setSearchTerm: (value: string) => void + setCurrentPage: (page: number) => void + setShowQueueAnalysis: (show: boolean) => void + setShowScreenshots: (show: boolean) => void + setShow3DViewer: (show: boolean) => void + setShowTimelineViewer: (show: boolean) => void + setPendingSSIMThreshold: (value: number) => void + handleSSIMRecalculate: () => void +} + +const RequestFilters: React.FC = ({ + httpRequests, + screenshots, + screenshotsLoading, + resourceTypes, + protocols, + hostnames, + priorities, + filteredRequests, + timelineEntries, + resourceTypeFilter, + protocolFilter, + hostnameFilter, + priorityFilter, + searchTerm, + showQueueAnalysis, + showScreenshots, + show3DViewer, + showTimelineViewer, + pendingSSIMThreshold, + ssimThreshold, + setResourceTypeFilter, + setProtocolFilter, + setHostnameFilter, + setPriorityFilter, + setSearchTerm, + setCurrentPage, + setShowQueueAnalysis, + setShowScreenshots, + setShow3DViewer, + setShowTimelineViewer, + setPendingSSIMThreshold, + handleSSIMRecalculate +}) => { + return ( +
+ {/* Resource Type Filter */} +
+ + +
+ + {/* Protocol Filter */} +
+ + +
+ + {/* Hostname Filter */} +
+ + +
+ + {/* Priority Filter */} +
+ + +
+ + {/* Search */} +
+ + { + setSearchTerm(e.target.value) + setCurrentPage(1) + }} + className={styles.searchInput} + /> +
+ + {/* Queue Analysis Toggle */} +
+ + +
+ + {/* 3D Viewer Toggle */} + {httpRequests.length > 0 && ( +
+ + + +
+ )} + + {/* Screenshots Toggle */} + {(screenshots.length > 0 || screenshotsLoading) && ( +
+ + + {screenshots.length > 0 && !screenshotsLoading && ( +
+
+ + + {ssimThreshold !== pendingSSIMThreshold && ( + + (Click Recalculate to apply) + + )} +
+
+ Lower values = more screenshots kept (more sensitive to changes) +
+
+ )} +
+ )} + + {/* Results count */} +
+ {showScreenshots ? + `${timelineEntries.length.toLocaleString()} timeline entries` : + `${filteredRequests.length.toLocaleString()} requests found`} +
+
+ ) +} + +export default RequestFilters \ No newline at end of file diff --git a/src/components/httprequestviewer/RequestRowDetails.tsx b/src/components/httprequestviewer/RequestRowDetails.tsx new file mode 100644 index 0000000..65ef60a --- /dev/null +++ b/src/components/httprequestviewer/RequestRowDetails.tsx @@ -0,0 +1,224 @@ +import React from 'react' +import type { HTTPRequest } from './types/httpRequest' +import styles from './HTTPRequestViewer.module.css' + +// Import utility functions +import { formatDuration, formatSize } from './lib/formatUtils' +import { + getTotalResponseTimeColor, + getQueueAnalysisIcon, + getCDNIcon, + getCDNDisplayName +} from './lib/colorUtils' + +interface RequestRowDetailsProps { + request: HTTPRequest +} + +const RequestRowDetails: React.FC = ({ request }) => { + return ( + + +
+ + {/* Request Details */} +
+

Request Details

+
+
Request ID: {request.requestId}
+
Method: {request.method}
+
Priority: {request.priority}
+
MIME Type: {request.mimeType || '-'}
+
Content-Length: {request.contentLength ? formatSize(request.contentLength) : '-'}
+
From Cache: {request.fromCache ? 'Yes' : 'No'}
+
Connection Reused: {request.connectionReused ? 'Yes' : 'No'}
+
+
+ + {/* Network Timing */} +
+

Network Timing

+
+
Start Time: {formatDuration(request.timing.startOffset)}
+
+ Queue Time: {formatDuration(request.timing.queueTime)} + {request.queueAnalysis && ( + + {getQueueAnalysisIcon(request.queueAnalysis)} + + )} +
+
Server Latency: {formatDuration(request.timing.serverLatency)}
+
Network Duration: {formatDuration(request.timing.duration)}
+
+ Total Response Time: {formatDuration(request.timing.totalResponseTime)} +
+
Network Duration Only: {formatDuration(request.timing.networkDuration)}
+ + {/* DNS Timing */} +
+ DNS: { + request.timing.dnsStart !== undefined && request.timing.dnsEnd !== undefined && request.timing.dnsStart >= 0 && request.timing.dnsEnd >= 0 + ? formatDuration((request.timing.dnsEnd || 0) - (request.timing.dnsStart || 0)) + : cached + } +
+ + {/* Connection Timing */} +
+ Connection: { + request.timing.connectStart !== undefined && request.timing.connectEnd !== undefined && request.timing.connectStart >= 0 && request.timing.connectEnd >= 0 + ? formatDuration((request.timing.connectEnd || 0) - (request.timing.connectStart || 0)) + : + {request.connectionReused ? 'reused' : 'cached'} + + } +
+ + {/* SSL Timing (only show if HTTPS and timing available) */} + {(request.url.startsWith('https://') || request.protocol === 'h2') && ( +
+ SSL: { + request.timing.sslStart !== undefined && request.timing.sslEnd !== undefined && request.timing.sslStart >= 0 && request.timing.sslEnd >= 0 + ? formatDuration((request.timing.sslEnd || 0) - (request.timing.sslStart || 0)) + : reused + } +
+ )} + + {/* Send Timing */} + {request.timing.sendStart !== undefined && request.timing.sendEnd !== undefined && request.timing.sendStart >= 0 && request.timing.sendEnd >= 0 && ( +
Send: {formatDuration((request.timing.sendEnd || 0) - (request.timing.sendStart || 0))}
+ )} + + {/* Receive Headers Timing */} + {request.timing.receiveHeadersStart !== undefined && request.timing.receiveHeadersEnd !== undefined && request.timing.receiveHeadersStart >= 0 && request.timing.receiveHeadersEnd >= 0 && ( +
Receive Headers: {formatDuration((request.timing.receiveHeadersEnd || 0) - (request.timing.receiveHeadersStart || 0))}
+ )} +
+
+ + {/* Queue Analysis */} + {request.queueAnalysis && request.queueAnalysis.reason !== 'unknown' && ( +
+

+ Queue Analysis {getQueueAnalysisIcon(request.queueAnalysis)} +

+
+
Reason: {request.queueAnalysis.description}
+
Concurrent Requests: {request.queueAnalysis.concurrentRequests}
+ {request.queueAnalysis.relatedRequests && request.queueAnalysis.relatedRequests.length > 0 && ( +
+ Related Request IDs:{' '} + + {request.queueAnalysis.relatedRequests.join(', ')} + {request.queueAnalysis.concurrentRequests > request.queueAnalysis.relatedRequests.length && + ` (+${request.queueAnalysis.concurrentRequests - request.queueAnalysis.relatedRequests.length} more)`} + +
+ )} +
+
+ )} + + {/* CDN Analysis */} + {request.cdnAnalysis && request.cdnAnalysis.provider !== 'unknown' && ( +
+

+ CDN Analysis {getCDNIcon(request.cdnAnalysis)} +

+
+
Provider: {getCDNDisplayName(request.cdnAnalysis.provider)}
+
Source: {request.cdnAnalysis.isEdge ? 'Edge Server' : 'Origin Server'}
+ {request.cdnAnalysis.cacheStatus && request.cdnAnalysis.cacheStatus !== 'unknown' && ( +
Cache Status: {request.cdnAnalysis.cacheStatus.toUpperCase()}
+ )} + {request.cdnAnalysis.edgeLocation && ( +
Edge Location: {request.cdnAnalysis.edgeLocation}
+ )} +
Confidence: {(request.cdnAnalysis.confidence * 100).toFixed(0)}%
+
Detection Method: {request.cdnAnalysis.detectionMethod}
+ + {/* Debug info for canadiantire.ca requests */} + {request.hostname.includes('canadiantire.ca') && ( +
+
+ Debug - CDN Detection Analysis: +
+
+ Current Detection: {request.cdnAnalysis?.provider} + (confidence: {((request.cdnAnalysis?.confidence || 0) * 100).toFixed(0)}%) +
+
+ All CDN-Related Headers: +
+ {request.responseHeaders && request.responseHeaders.map((header, idx) => { + const headerName = header.name.toLowerCase() + // Show all potentially CDN-related headers + if (headerName.includes('akamai') || + headerName.includes('fastly') || + headerName.includes('server') || + headerName.includes('via') || + headerName.includes('x-cache') || + headerName.includes('x-serial') || + headerName.includes('x-served-by') || + headerName.includes('cf-') || + headerName.includes('x-amz-cf') || + headerName.includes('azure') || + headerName.includes('x-goog') || + headerName.includes('cdn') || + headerName.includes('edge') || + headerName.includes('cache')) { + const isAkamaiIndicator = headerName.includes('akamai') || + headerName.includes('x-serial') || + (headerName === 'x-cache' && header.value.includes('tcp_')) || + (headerName === 'x-served-by' && header.value.includes('cache-')) + return ( +
+ + {header.name}: + {header.value} + {isAkamaiIndicator && ( + + ← AKAMAI + + )} +
+ ) + } + return null + })} +
+ )} +
+
+ )} + + {/* Response Headers */} + {request.responseHeaders && request.responseHeaders.length > 0 && ( +
+

+ Response Headers ({request.responseHeaders.length}) +

+
+ {request.responseHeaders.map((header, index) => ( +
+
+ {header.name}: +
+
+ {header.value} +
+
+ ))} +
+
+ )} + +
+ + + ) +} + +export default RequestRowDetails \ No newline at end of file diff --git a/src/components/httprequestviewer/RequestRowSummary.tsx b/src/components/httprequestviewer/RequestRowSummary.tsx new file mode 100644 index 0000000..3a74ed2 --- /dev/null +++ b/src/components/httprequestviewer/RequestRowSummary.tsx @@ -0,0 +1,139 @@ +import React from 'react' +import RequestRowDetails from './RequestRowDetails' +import styles from './HTTPRequestViewer.module.css' +import type { HTTPRequest } from './types/httpRequest' + +// Import utility functions +import { formatDuration, formatSize } from './lib/formatUtils' +import { + getStatusColor, + getProtocolColor, + getPriorityColor, + getSizeColor, + getDurationColor, + getTotalResponseTimeColor, + getServerLatencyColor, + getQueueAnalysisIcon, + getCDNIcon, + getCDNDisplayName +} from './lib/colorUtils' +import { truncateUrl } from './lib/urlUtils' +import { HIGHLIGHTING_CONFIG } from './lib/httpRequestConstants' + +interface RequestRowSummaryProps { + request: HTTPRequest + showQueueAnalysis: boolean + isExpanded: boolean + onToggleRowExpansion: (requestId: string) => void +} + +const RequestRowSummary: React.FC = ({ + request, + showQueueAnalysis, + isExpanded, + onToggleRowExpansion +}) => { + return ( + <> + onToggleRowExpansion(request.requestId)} + > + + {isExpanded ? '−' : '+'} + + + {request.method} + + + {request.statusCode || '-'} + + + {request.resourceType} + + + {request.priority || '-'} + + + {formatDuration(request.timing.startOffset)} + + HIGHLIGHTING_CONFIG.QUEUE_TIME.HIGH_THRESHOLD ? HIGHLIGHTING_CONFIG.COLORS.STATUS.SERVER_ERROR : '#495057', + fontWeight: request.timing.queueTime && request.timing.queueTime > HIGHLIGHTING_CONFIG.QUEUE_TIME.HIGH_THRESHOLD ? 'bold' : 'normal' + }}> +
+ {formatDuration(request.timing.queueTime)} + {showQueueAnalysis && request.queueAnalysis && ( + + {getQueueAnalysisIcon(request.queueAnalysis)} + + )} +
+ + + {/* DNS Time */} + + {request.timing.dnsStart !== undefined && request.timing.dnsEnd !== undefined && request.timing.dnsStart >= 0 && request.timing.dnsEnd >= 0 + ? formatDuration((request.timing.dnsEnd || 0) - (request.timing.dnsStart || 0)) + : cached + } + + + {/* Connection Time */} + + {request.timing.connectStart !== undefined && request.timing.connectEnd !== undefined && request.timing.connectStart >= 0 && request.timing.connectEnd >= 0 + ? formatDuration((request.timing.connectEnd || 0) - (request.timing.connectStart || 0)) + : + {request.connectionReused ? 'reused' : 'cached'} + + } + + + + {formatDuration(request.timing.serverLatency)} + + + + {truncateUrl(request.url)} + + + + {formatDuration(request.timing.duration)} + + + {/* Total Response Time */} + + {formatDuration(request.timing.totalResponseTime)} + + + + {formatSize(request.encodedDataLength)} + + + {request.contentLength ? formatSize(request.contentLength) : '-'} + + + {request.protocol || '-'} + + + {request.cdnAnalysis ? getCDNIcon(request.cdnAnalysis) : '-'} + + + {request.fromCache ? 'ðŸ’ū' : request.connectionReused ? '🔄' : '🌐'} + + + + {/* Expanded Row Details */} + {isExpanded && } + + ) +} + +export default RequestRowSummary \ No newline at end of file diff --git a/src/components/httprequestviewer/RequestsTable.tsx b/src/components/httprequestviewer/RequestsTable.tsx new file mode 100644 index 0000000..ac149b2 --- /dev/null +++ b/src/components/httprequestviewer/RequestsTable.tsx @@ -0,0 +1,106 @@ +import React from 'react' +import RequestRowSummary from './RequestRowSummary' +import ScreenshotRow from './ScreenshotRow' +import styles from './HTTPRequestViewer.module.css' +import type { HTTPRequest, ScreenshotEvent } from './types/httpRequest' + +interface RequestsTableProps { + // Data + httpRequests: HTTPRequest[] + showScreenshots: boolean + paginatedTimelineEntries: Array<{ + type: 'request' | 'screenshot' + timestamp: number + data: HTTPRequest | ScreenshotEvent + }> + paginatedRequests: HTTPRequest[] + + // Display options + showQueueAnalysis: boolean + + // Row expansion state + expandedRows: Set + onToggleRowExpansion: (requestId: string) => void +} + +const RequestsTable: React.FC = ({ + httpRequests, + showScreenshots, + paginatedTimelineEntries, + paginatedRequests, + showQueueAnalysis, + expandedRows, + onToggleRowExpansion +}) => { + return ( + <> + {/* Requests Table */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + {(showScreenshots ? paginatedTimelineEntries : paginatedRequests.map(req => ({ type: 'request' as const, timestamp: req.timing.start, data: req }))).map((entry) => { + if (entry.type === 'screenshot') { + const screenshot = entry.data as ScreenshotEvent + const firstRequestTime = httpRequests.length > 0 ? httpRequests[0].timing.start : 0 + + return ( + + ) + } + + const request = entry.data as HTTPRequest + const isExpanded = expandedRows.has(request.requestId) + + return ( + + ) + })} + +
ExpandMethodStatusTypePriorityStart TimeQueue TimeDNSConnectionServer LatencyURLDurationTotal Response TimeSizeContent-LengthProtocolCDNCache
+
+ + {/* No Results Message */} + {(showScreenshots ? paginatedTimelineEntries.length === 0 : paginatedRequests.length === 0) && ( +
+ {showScreenshots ? + 'No timeline entries found matching the current filters' : + 'No HTTP requests found matching the current filters'} +
+ )} + + ) +} + +export default RequestsTable \ No newline at end of file diff --git a/src/components/httprequestviewer/ScreenshotRow.tsx b/src/components/httprequestviewer/ScreenshotRow.tsx new file mode 100644 index 0000000..b44b45d --- /dev/null +++ b/src/components/httprequestviewer/ScreenshotRow.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import styles from './HTTPRequestViewer.module.css' +import type { ScreenshotEvent } from './types/httpRequest' +import { formatDuration } from './lib/formatUtils' + +interface ScreenshotRowProps { + screenshot: ScreenshotEvent + firstRequestTime: number +} + +const ScreenshotRow: React.FC = ({ + screenshot, + firstRequestTime +}) => { + const timeOffset = screenshot.timestamp - firstRequestTime + + const handleScreenshotClick = () => { + // Open screenshot in new window for full size viewing + const newWindow = window.open('', '_blank') + if (newWindow) { + newWindow.document.write(` + + Screenshot at ${formatDuration(timeOffset)} + + Full size screenshot + + + `) + } + } + + return ( + + +
+ ðŸ“ļ Screenshot +
+
+ Time: {formatDuration(timeOffset)} +
+ {`Screenshot +
+ Click to view full size +
+ + + ) +} + +export default ScreenshotRow \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/addTimingToRequest.ts b/src/components/httprequestviewer/lib/addTimingToRequest.ts new file mode 100644 index 0000000..ef8507e --- /dev/null +++ b/src/components/httprequestviewer/lib/addTimingToRequest.ts @@ -0,0 +1,34 @@ +import type {HTTPRequest} from "../types/httpRequest.ts"; + +export default function addTimingToRequest(request: HTTPRequest, timingData: any) { + if (timingData.dnsStart >= 0 && timingData.dnsEnd >= 0) { + request.timing.dnsStart = timingData.dnsStart * 1000 // Convert to microseconds + request.timing.dnsEnd = timingData.dnsEnd * 1000 + } + + // Extract connection timing + if (timingData.connectStart >= 0 && timingData.connectEnd >= 0) { + request.timing.connectStart = timingData.connectStart * 1000 // Convert to microseconds + request.timing.connectEnd = timingData.connectEnd * 1000 + } + + // Extract SSL timing if available + if (timingData.sslStart >= 0 && timingData.sslEnd >= 0) { + request.timing.sslStart = timingData.sslStart * 1000 // Convert to microseconds + request.timing.sslEnd = timingData.sslEnd * 1000 + } + + // Extract send/receive timing + if (timingData.sendStart >= 0) { + request.timing.sendStart = timingData.sendStart * 1000 + } + if (timingData.sendEnd >= 0) { + request.timing.sendEnd = timingData.sendEnd * 1000 + } + if (timingData.receiveHeadersStart >= 0) { + request.timing.receiveHeadersStart = timingData.receiveHeadersStart * 1000 + } + if (timingData.receiveHeadersEnd >= 0) { + request.timing.receiveHeadersEnd = timingData.receiveHeadersEnd * 1000 + } +} \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/analysisUtils.ts b/src/components/httprequestviewer/lib/analysisUtils.ts new file mode 100644 index 0000000..1179e4d --- /dev/null +++ b/src/components/httprequestviewer/lib/analysisUtils.ts @@ -0,0 +1,250 @@ +import type { HTTPRequest, CDNAnalysis, QueueAnalysis } from '../types/httpRequest' +import { HIGHLIGHTING_CONFIG } from './httpRequestConstants' + +export const analyzeCDN = (request: HTTPRequest): CDNAnalysis => { + const hostname = request.hostname.toLowerCase() + const headers = request.responseHeaders || [] + const headerMap = new Map(headers.map(h => [h.name.toLowerCase(), h.value.toLowerCase()])) + + let provider: CDNAnalysis['provider'] = 'unknown' + let isEdge = false + let cacheStatus: CDNAnalysis['cacheStatus'] = 'unknown' + let edgeLocation: string | undefined + let confidence = 0 + let detectionMethod = 'unknown' + + // Enhanced Akamai detection (check this first as it's often missed) + if (headerMap.has('akamai-cache-status') || + headerMap.has('x-cache-key') || + headerMap.has('x-akamai-request-id') || + headerMap.has('x-akamai-edgescape') || + headerMap.get('server')?.includes('akamai') || + headerMap.get('server')?.includes('akamainet') || + headerMap.get('server')?.includes('akamainetworking') || + headerMap.get('x-cache')?.includes('akamai') || + hostname.includes('akamai') || + hostname.includes('akamaized') || + hostname.includes('akamaicdn') || + hostname.includes('akamai-staging') || + // Check for Akamai Edge hostnames patterns + /e\d+\.a\.akamaiedge\.net/.test(hostname) || + // Check Via header for Akamai + headerMap.get('via')?.includes('akamai') || + // Check for Akamai Image Manager server header + (headerMap.get('server') === 'akamai image manager') || + // Check for typical Akamai headers + headerMap.has('x-serial') || + // Additional Akamai detection patterns + headerMap.has('x-akamai-session-info') || + headerMap.has('x-akamai-ssl-client-sid') || + headerMap.get('x-cache')?.includes('tcp_hit') || + headerMap.get('x-cache')?.includes('tcp_miss') || + // Akamai sometimes uses x-served-by with specific patterns + (headerMap.has('x-served-by') && headerMap.get('x-served-by')?.includes('cache-'))) { + provider = 'akamai' + confidence = 0.95 + detectionMethod = 'enhanced akamai detection (headers/hostname/server)' + isEdge = true + + const akamaiStatus = headerMap.get('akamai-cache-status') + if (akamaiStatus) { + if (akamaiStatus.includes('hit')) cacheStatus = 'hit' + else if (akamaiStatus.includes('miss')) cacheStatus = 'miss' + } + + // Also check x-cache for Akamai cache status + const xCache = headerMap.get('x-cache') + if (xCache && !akamaiStatus) { + if (xCache.includes('hit')) cacheStatus = 'hit' + else if (xCache.includes('miss')) cacheStatus = 'miss' + else if (xCache.includes('tcp_hit')) cacheStatus = 'hit' + else if (xCache.includes('tcp_miss')) cacheStatus = 'miss' + } + } + + // Cloudflare detection + else if (headerMap.has('cf-ray') || headerMap.has('cf-cache-status') || hostname.includes('cloudflare')) { + provider = 'cloudflare' + confidence = 0.95 + detectionMethod = 'headers (cf-ray/cf-cache-status)' + isEdge = true + + const cfCacheStatus = headerMap.get('cf-cache-status') + if (cfCacheStatus) { + if (cfCacheStatus.includes('hit')) cacheStatus = 'hit' + else if (cfCacheStatus.includes('miss')) cacheStatus = 'miss' + else if (cfCacheStatus.includes('expired')) cacheStatus = 'expired' + else if (cfCacheStatus.includes('bypass')) cacheStatus = 'bypass' + } + + edgeLocation = headerMap.get('cf-ray')?.split('-')[1] + } + + // AWS CloudFront detection + else if (headerMap.has('x-amz-cf-id') || headerMap.has('x-amz-cf-pop') || + hostname.includes('cloudfront.net')) { + provider = 'aws_cloudfront' + confidence = 0.95 + detectionMethod = 'headers (x-amz-cf-id/x-amz-cf-pop)' + isEdge = true + + const cloudfrontCache = headerMap.get('x-cache') + if (cloudfrontCache) { + if (cloudfrontCache.includes('hit')) cacheStatus = 'hit' + else if (cloudfrontCache.includes('miss')) cacheStatus = 'miss' + } + + edgeLocation = headerMap.get('x-amz-cf-pop') + } + + // Fastly detection (moved after Akamai to avoid false positives) + else if (headerMap.has('fastly-debug-digest') || + headerMap.has('fastly-debug-path') || + hostname.includes('fastly.com') || + hostname.includes('fastlylb.net') || + headerMap.get('via')?.includes('fastly') || + headerMap.get('x-cache')?.includes('fastly') || + // Only use x-served-by for Fastly if it has specific Fastly patterns + (headerMap.has('x-served-by') && ( + headerMap.get('x-served-by')?.includes('fastly') || + headerMap.get('x-served-by')?.includes('f1') || + headerMap.get('x-served-by')?.includes('f2') + ))) { + provider = 'fastly' + confidence = 0.85 + detectionMethod = 'headers (fastly-debug-digest/via/x-cache)' + isEdge = true + + const fastlyCache = headerMap.get('x-cache') + if (fastlyCache) { + if (fastlyCache.includes('hit')) cacheStatus = 'hit' + else if (fastlyCache.includes('miss')) cacheStatus = 'miss' + } + } + + // Azure CDN detection + else if (headerMap.has('x-azure-ref') || hostname.includes('azureedge.net')) { + provider = 'azure_cdn' + confidence = 0.9 + detectionMethod = 'headers (x-azure-ref) or hostname' + isEdge = true + } + + // Google CDN detection + else if (headerMap.has('x-goog-generation') || hostname.includes('googleapis.com') || + headerMap.get('server')?.includes('gws')) { + provider = 'google_cdn' + confidence = 0.8 + detectionMethod = 'headers (x-goog-generation) or server' + isEdge = true + } + + // Generic CDN patterns + else if (hostname.includes('cdn') || hostname.includes('cache') || + hostname.includes('edge') || hostname.includes('static')) { + provider = 'unknown' + confidence = 0.3 + detectionMethod = 'hostname patterns (cdn/cache/edge/static)' + isEdge = true + } + + // Check for origin indicators (lower confidence for edge) + if (hostname.includes('origin') || hostname.includes('api') || + headerMap.get('x-cache')?.includes('miss from origin')) { + isEdge = false + confidence = Math.max(0.1, confidence - 0.2) + detectionMethod += ' + origin indicators' + } + + return { + provider, + isEdge, + cacheStatus, + edgeLocation, + confidence, + detectionMethod + } +} + +export const analyzeQueueReason = (request: HTTPRequest, allRequests: HTTPRequest[]): QueueAnalysis => { + const queueTime = request.timing.queueTime || 0 + + // If no significant queue time, no analysis needed + if (queueTime < HIGHLIGHTING_CONFIG.QUEUE_TIME.ANALYSIS_THRESHOLD) { + return { + reason: 'unknown', + description: 'No significant queueing detected', + concurrentRequests: 0 + } + } + + const requestStart = request.timing.start + const requestEnd = request.timing.end || requestStart + (request.timing.duration || 0) + + // Find concurrent requests to the same host + const concurrentToSameHost = allRequests.filter(other => { + if (other.requestId === request.requestId) return false + if (other.hostname !== request.hostname) return false + + const otherStart = other.timing.start + const otherEnd = other.timing.end || otherStart + (other.timing.duration || 0) + + // Check if requests overlap in time + return (otherStart <= requestEnd && otherEnd >= requestStart) + }) + + const concurrentSameProtocol = concurrentToSameHost.filter(r => r.protocol === request.protocol) + + // HTTP/1.1 connection limit analysis (typically 6 connections per host) + if (request.protocol === 'http/1.1' && concurrentSameProtocol.length >= 6) { + return { + reason: 'connection_limit', + description: `HTTP/1.1 connection limit reached (${concurrentSameProtocol.length} concurrent requests)`, + concurrentRequests: concurrentSameProtocol.length, + relatedRequests: concurrentSameProtocol.slice(0, 5).map(r => r.requestId) + } + } + + // H2 multiplexing but still queued - likely priority or server limits + if (request.protocol === 'h2' && concurrentSameProtocol.length > 0) { + // Check if lower priority requests are being processed first + const lowerPriorityActive = concurrentSameProtocol.filter(r => { + const priorityOrder = ['VeryLow', 'Low', 'Medium', 'High', 'VeryHigh'] + const requestPriorityIndex = priorityOrder.indexOf(request.priority || 'Medium') + const otherPriorityIndex = priorityOrder.indexOf(r.priority || 'Medium') + return otherPriorityIndex < requestPriorityIndex + }) + + if (lowerPriorityActive.length > 0) { + return { + reason: 'priority_queue', + description: `Queued behind ${lowerPriorityActive.length} lower priority requests`, + concurrentRequests: concurrentSameProtocol.length, + relatedRequests: lowerPriorityActive.slice(0, 3).map(r => r.requestId) + } + } + + return { + reason: 'resource_contention', + description: `Server resource contention (${concurrentSameProtocol.length} concurrent H2 requests)`, + concurrentRequests: concurrentSameProtocol.length, + relatedRequests: concurrentSameProtocol.slice(0, 3).map(r => r.requestId) + } + } + + // General resource contention + if (concurrentToSameHost.length > 0) { + return { + reason: 'resource_contention', + description: `Resource contention with ${concurrentToSameHost.length} concurrent requests`, + concurrentRequests: concurrentToSameHost.length, + relatedRequests: concurrentToSameHost.slice(0, 3).map(r => r.requestId) + } + } + + return { + reason: 'unknown', + description: `Queued for ${(queueTime / 1000).toFixed(1)}ms - reason unclear`, + concurrentRequests: 0 + } +} \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/colorUtils.ts b/src/components/httprequestviewer/lib/colorUtils.ts new file mode 100644 index 0000000..b7f1a94 --- /dev/null +++ b/src/components/httprequestviewer/lib/colorUtils.ts @@ -0,0 +1,130 @@ +import { HIGHLIGHTING_CONFIG } from './httpRequestConstants' +import type { QueueAnalysis, CDNAnalysis } from '../types/httpRequest' + +export const getStatusColor = (status?: number): string => { + if (!status) return HIGHLIGHTING_CONFIG.COLORS.STATUS.UNKNOWN + if (status >= 200 && status < 300) return HIGHLIGHTING_CONFIG.COLORS.STATUS.SUCCESS + if (status >= 300 && status < 400) return HIGHLIGHTING_CONFIG.COLORS.STATUS.REDIRECT + if (status >= 400 && status < 500) return HIGHLIGHTING_CONFIG.COLORS.STATUS.CLIENT_ERROR + if (status >= 500) return HIGHLIGHTING_CONFIG.COLORS.STATUS.SERVER_ERROR + return HIGHLIGHTING_CONFIG.COLORS.STATUS.UNKNOWN +} + +export const getProtocolColor = (protocol?: string): string => { + if (!protocol) return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.UNKNOWN + if (protocol === 'http/1.1') return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.HTTP1_1 + if (protocol === 'h2') return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.H2 + if (protocol === 'h3') return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.H3 + return HIGHLIGHTING_CONFIG.COLORS.PROTOCOL.UNKNOWN +} + +export const getPriorityColor = (priority?: string): string => { + if (!priority) return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.UNKNOWN + const upperPriority = priority.toUpperCase() + if (upperPriority === 'VERYHIGH') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.VERY_HIGH + if (upperPriority === 'HIGH') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.HIGH + if (upperPriority === 'MEDIUM') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.MEDIUM + if (upperPriority === 'LOW') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.LOW + if (upperPriority === 'VERYLOW') return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.VERY_LOW + return HIGHLIGHTING_CONFIG.COLORS.PRIORITY.UNKNOWN +} + +export const getSizeColor = (bytes?: number) => { + if (!bytes) return HIGHLIGHTING_CONFIG.COLORS.SIZE.DEFAULT + + const kb = bytes / 1024 + if (kb >= HIGHLIGHTING_CONFIG.SIZE_THRESHOLDS.LARGE) { + return HIGHLIGHTING_CONFIG.COLORS.SIZE.LARGE + } else if (kb >= HIGHLIGHTING_CONFIG.SIZE_THRESHOLDS.MEDIUM) { + return HIGHLIGHTING_CONFIG.COLORS.SIZE.MEDIUM + } else if (kb <= HIGHLIGHTING_CONFIG.SIZE_THRESHOLDS.SMALL) { + return HIGHLIGHTING_CONFIG.COLORS.SIZE.SMALL + } + + return HIGHLIGHTING_CONFIG.COLORS.SIZE.DEFAULT +} + +export const getDurationColor = (microseconds?: number) => { + if (!microseconds) return HIGHLIGHTING_CONFIG.COLORS.DURATION.DEFAULT + + const durationMs = microseconds / 1000 + + if (durationMs > HIGHLIGHTING_CONFIG.DURATION_THRESHOLDS.SLOW) { + return HIGHLIGHTING_CONFIG.COLORS.DURATION.SLOW + } else if (durationMs >= HIGHLIGHTING_CONFIG.DURATION_THRESHOLDS.MEDIUM) { + return HIGHLIGHTING_CONFIG.COLORS.DURATION.MEDIUM + } else if (durationMs < HIGHLIGHTING_CONFIG.DURATION_THRESHOLDS.FAST) { + return HIGHLIGHTING_CONFIG.COLORS.DURATION.FAST + } + + return HIGHLIGHTING_CONFIG.COLORS.DURATION.DEFAULT +} + +export const getTotalResponseTimeColor = (microseconds?: number) => { + if (!microseconds) return HIGHLIGHTING_CONFIG.COLORS.DURATION.DEFAULT + + const totalResponseTimeMs = microseconds / 1000 + + if (totalResponseTimeMs > HIGHLIGHTING_CONFIG.TOTAL_RESPONSE_TIME_THRESHOLDS.SLOW) { + return { backgroundColor: '#ffebee', color: '#c62828' } // Red for > 200ms + } else if (totalResponseTimeMs >= HIGHLIGHTING_CONFIG.TOTAL_RESPONSE_TIME_THRESHOLDS.FAST) { + return { backgroundColor: '#fff8e1', color: '#f57c00' } // Yellow for 100-200ms + } else { + return { backgroundColor: '#e8f5e8', color: '#2e7d32' } // Green for < 100ms + } +} + +export const getServerLatencyColor = (microseconds?: number) => { + if (!microseconds) return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.DEFAULT + + if (microseconds > HIGHLIGHTING_CONFIG.SERVER_LATENCY_THRESHOLDS.SLOW) { + return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.SLOW + } else if (microseconds >= HIGHLIGHTING_CONFIG.SERVER_LATENCY_THRESHOLDS.MEDIUM) { + return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.MEDIUM + } else if (microseconds < HIGHLIGHTING_CONFIG.SERVER_LATENCY_THRESHOLDS.FAST) { + return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.FAST + } + + return HIGHLIGHTING_CONFIG.COLORS.SERVER_LATENCY.DEFAULT +} + +export const getQueueAnalysisIcon = (analysis?: QueueAnalysis): string => { + if (!analysis) return '' + + switch (analysis.reason) { + case 'connection_limit': return '🔗' + case 'priority_queue': return '⚡' + case 'resource_contention': return '🏁' + default: return '❓' + } +} + +export const getCDNIcon = (analysis?: CDNAnalysis): string => { + if (!analysis) return '' + + switch (analysis.provider) { + case 'cloudflare': return '☁ïļ' + case 'akamai': return '🌐' + case 'aws_cloudfront': return '☁ïļ' + case 'fastly': return '⚡' + case 'azure_cdn': return '☁ïļ' + case 'google_cdn': return '🔍' + case 'cdn77': return '🌐' + case 'keycdn': return '🔑' + default: return '🌍' + } +} + +export const getCDNDisplayName = (provider: CDNAnalysis['provider']): string => { + switch (provider) { + case 'cloudflare': return 'Cloudflare' + case 'akamai': return 'Akamai' + case 'aws_cloudfront': return 'CloudFront' + case 'fastly': return 'Fastly' + case 'azure_cdn': return 'Azure CDN' + case 'google_cdn': return 'Google CDN' + case 'cdn77': return 'CDN77' + case 'keycdn': return 'KeyCDN' + default: return 'Unknown CDN' + } +} \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/filterUtils.ts b/src/components/httprequestviewer/lib/filterUtils.ts new file mode 100644 index 0000000..5e901df --- /dev/null +++ b/src/components/httprequestviewer/lib/filterUtils.ts @@ -0,0 +1,87 @@ +import type { HTTPRequest } from '../types/httpRequest' +import { ITEMS_PER_PAGE } from './httpRequestConstants' + +export interface FilterOptions { + statusFilter: string + resourceTypeFilter: string + hostnameFilter: string + priorityFilter: string + searchTerm: string +} + +export const filterRequests = (requests: HTTPRequest[], filters: FilterOptions): HTTPRequest[] => { + return requests.filter(request => { + // Status filter + if (filters.statusFilter !== 'all') { + const status = request.statusCode + if (filters.statusFilter === '2xx' && (!status || status < 200 || status >= 300)) return false + if (filters.statusFilter === '3xx' && (!status || status < 300 || status >= 400)) return false + if (filters.statusFilter === '4xx' && (!status || status < 400 || status >= 500)) return false + if (filters.statusFilter === '5xx' && (!status || status < 500)) return false + } + + // Resource type filter + if (filters.resourceTypeFilter !== 'all' && request.resourceType !== filters.resourceTypeFilter) { + return false + } + + // Hostname filter + if (filters.hostnameFilter !== 'all' && request.hostname !== filters.hostnameFilter) { + return false + } + + // Priority filter + if (filters.priorityFilter !== 'all' && request.priority !== filters.priorityFilter) { + return false + } + + // Search filter + if (filters.searchTerm && !request.url.toLowerCase().includes(filters.searchTerm.toLowerCase())) { + return false + } + + return true + }) +} + +export const paginateRequests = (requests: HTTPRequest[], currentPage: number): HTTPRequest[] => { + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE + const endIndex = startIndex + ITEMS_PER_PAGE + return requests.slice(startIndex, endIndex) +} + +export const createTimelineEntries = (requests: HTTPRequest[]): Array<{ request: HTTPRequest; timing: any }> => { + return requests + .filter(request => request.timing.startOffset !== undefined) + .map(request => ({ + request, + timing: { + startOffset: request.timing.startOffset, + duration: request.timing.duration || 0, + queueTime: request.timing.queueTime || 0, + serverLatency: request.timing.serverLatency || 0 + } + })) + .sort((a, b) => (a.timing.startOffset || 0) - (b.timing.startOffset || 0)) +} + +export const getUniqueValues = (items: T[], key: keyof T): string[] => { + const values = items.map(item => String(item[key])).filter(Boolean) + return Array.from(new Set(values)).sort() +} + +export const getResourceTypes = (requests: HTTPRequest[]): string[] => { + return getUniqueValues(requests, 'resourceType') +} + +export const getProtocols = (requests: HTTPRequest[]): string[] => { + return getUniqueValues(requests, 'protocol') +} + +export const getHostnames = (requests: HTTPRequest[]): string[] => { + return getUniqueValues(requests, 'hostname') +} + +export const getPriorities = (requests: HTTPRequest[]): string[] => { + return getUniqueValues(requests, 'priority') +} \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/formatUtils.ts b/src/components/httprequestviewer/lib/formatUtils.ts new file mode 100644 index 0000000..d675193 --- /dev/null +++ b/src/components/httprequestviewer/lib/formatUtils.ts @@ -0,0 +1,13 @@ +export const formatDuration = (microseconds?: number): string => { + if (!microseconds) return 'N/A' + if (microseconds < 1000) return `${microseconds.toFixed(0)}Ξs` + if (microseconds < 1000000) return `${(microseconds / 1000).toFixed(1)}ms` + return `${(microseconds / 1000000).toFixed(2)}s` +} + +export const formatSize = (bytes?: number): string => { + if (!bytes) return 'N/A' + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / 1024 / 1024).toFixed(1)}MB` +} \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/httpRequestConstants.ts b/src/components/httprequestviewer/lib/httpRequestConstants.ts new file mode 100644 index 0000000..ca6ab4f --- /dev/null +++ b/src/components/httprequestviewer/lib/httpRequestConstants.ts @@ -0,0 +1,96 @@ +export const ITEMS_PER_PAGE = 25 + +// SSIM threshold for screenshot similarity (0-1, where 1 is identical) +// Values above this threshold are considered "similar enough" to filter out +export const SSIM_SIMILARITY_THRESHOLD = 0.95 + +// Global highlighting constants +export const HIGHLIGHTING_CONFIG = { + // File size thresholds (in KB) + SIZE_THRESHOLDS: { + LARGE: 500, // Red highlighting for files >= 500KB + MEDIUM: 100, // Yellow highlighting for files >= 100KB + SMALL: 50 // Green highlighting for files <= 50KB + }, + + // Duration thresholds (in milliseconds) + DURATION_THRESHOLDS: { + SLOW: 350, // Red highlighting for requests > 150ms + MEDIUM: 200, // Yellow highlighting for requests 50-150ms + FAST: 200 // Green highlighting for requests < 50ms + }, + + // Total Response Time thresholds (in milliseconds) + TOTAL_RESPONSE_TIME_THRESHOLDS: { + FAST: 100, // Green highlighting for total response < 100ms + MEDIUM: 200, // Yellow highlighting for total response 100-200ms + SLOW: 200 // Red highlighting for total response > 200ms + }, + + // Server latency thresholds (in microseconds) + SERVER_LATENCY_THRESHOLDS: { + SLOW: 250000, // Red highlighting for server latency > 200ms (200000 microseconds) + MEDIUM: 100000, // Yellow highlighting for server latency 50-200ms (50000 microseconds) + FAST: 100000 // Green highlighting for server latency < 50ms (50000 microseconds) + }, + + // Queue time thresholds (in microseconds) + QUEUE_TIME: { + HIGH_THRESHOLD: 10000, // 10ms - highlight in red and show analysis + ANALYSIS_THRESHOLD: 10000 // 10ms - minimum queue time for analysis + }, + + // Colors + COLORS: { + // Status colors + STATUS: { + SUCCESS: '#28a745', // 2xx + REDIRECT: '#ffc107', // 3xx + CLIENT_ERROR: '#fd7e14', // 4xx + SERVER_ERROR: '#dc3545', // 5xx + UNKNOWN: '#6c757d' + }, + + // Protocol colors + PROTOCOL: { + HTTP1_1: '#dc3545', // Red + H2: '#b8860b', // Dark yellow + H3: '#006400', // Dark green + UNKNOWN: '#6c757d' + }, + + // Priority colors + PRIORITY: { + VERY_HIGH: '#dc3545', // Red + HIGH: '#fd7e14', // Orange + MEDIUM: '#ffc107', // Yellow + LOW: '#28a745', // Green + VERY_LOW: '#6c757d', // Gray + UNKNOWN: '#6c757d' + }, + + // File size colors + SIZE: { + LARGE: { background: '#dc3545', color: 'white' }, // Red for 500KB+ + MEDIUM: { background: '#ffc107', color: 'black' }, // Yellow for 100-500KB + SMALL: { background: '#28a745', color: 'white' }, // Green for under 50KB + DEFAULT: { background: 'transparent', color: '#495057' } // Default for 50-100KB + }, + + // Duration colors + DURATION: { + SLOW: { background: '#dc3545', color: 'white' }, // Red for > 150ms + MEDIUM: { background: '#ffc107', color: 'black' }, // Yellow for 50-150ms + FAST: { background: '#28a745', color: 'white' }, // Green for < 50ms + DEFAULT: { background: 'transparent', color: '#495057' } // Default + }, + + // Server latency colors + SERVER_LATENCY: { + SLOW: { background: '#dc3545', color: 'white' }, // Red for > 200ms + MEDIUM: { background: '#ffc107', color: 'black' }, // Yellow for 50-200ms + FAST: { background: '#28a745', color: 'white' }, // Green for < 50ms + DEFAULT: { background: 'transparent', color: '#495057' } // Default + } + } +} \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/httpRequestProcessor.ts b/src/components/httprequestviewer/lib/httpRequestProcessor.ts new file mode 100644 index 0000000..a90768b --- /dev/null +++ b/src/components/httprequestviewer/lib/httpRequestProcessor.ts @@ -0,0 +1,295 @@ +import type { TraceEvent } from '../../../types/trace' +import type { HTTPRequest } from '../types/httpRequest' +import { getHostnameFromUrl } from './urlUtils' + +export const processHTTPRequests = (traceEvents: TraceEvent[]): HTTPRequest[] => { + const requestsMap = new Map() + + // Process all events and group by requestId + for (const event of traceEvents) { + const args = event.args as any + const requestId = args?.data?.requestId + + if (!requestId) continue + + if (!requestsMap.has(requestId)) { + requestsMap.set(requestId, { + requestId, + url: '', + hostname: '', + method: '', + resourceType: '', + priority: '', + events: {}, + timing: { start: event.ts }, + fromCache: false, + connectionReused: false + }) + } + + const request = requestsMap.get(requestId)! + + switch (event.name) { + case 'ResourceWillSendRequest': + request.events.willSendRequest = event + request.timing.start = Math.min(request.timing.start, event.ts) + break + + case 'ResourceSendRequest': + request.events.sendRequest = event + request.url = args.data.url || '' + request.hostname = getHostnameFromUrl(request.url) + request.method = args.data.requestMethod || '' + request.resourceType = args.data.resourceType || '' + request.priority = args.data.priority || '' + request.timing.start = Math.min(request.timing.start, event.ts) + break + + case 'ResourceReceiveResponse': + request.events.receiveResponse = event + request.statusCode = args.data.statusCode + request.mimeType = args.data.mimeType + request.protocol = args.data.protocol + request.responseHeaders = args.data.headers + + // Extract content-length from response headers + if (request.responseHeaders) { + const contentLengthHeader = request.responseHeaders.find( + header => header.name.toLowerCase() === 'content-length' + ) + if (contentLengthHeader) { + request.contentLength = parseInt(contentLengthHeader.value, 10) + } + } + + request.fromCache = args.data.fromCache || false + request.connectionReused = args.data.connectionReused || false + request.timing.end = event.ts + + // Extract network timing details + const timing = args.data.timing + if (timing) { + request.timing.networkDuration = timing.receiveHeadersEnd + request.timing.dnsStart = timing.dnsStart + request.timing.dnsEnd = timing.dnsEnd + request.timing.connectStart = timing.connectStart + request.timing.connectEnd = timing.connectEnd + request.timing.sslStart = timing.sslStart + request.timing.sslEnd = timing.sslEnd + request.timing.sendStart = timing.sendStart + request.timing.sendEnd = timing.sendEnd + request.timing.receiveHeadersStart = timing.receiveHeadersStart + request.timing.receiveHeadersEnd = timing.receiveHeadersEnd + } + break + + case 'ResourceReceivedData': + if (!request.events.receivedData) request.events.receivedData = [] + request.events.receivedData.push(event) + request.encodedDataLength = (request.encodedDataLength || 0) + (args.data.encodedDataLength || 0) + request.timing.end = Math.max(request.timing.end || event.ts, event.ts) + break + + case 'ResourceFinishLoading': + request.events.finishLoading = event + request.timing.end = event.ts + break + } + + // Calculate durations and timing + calculateDurations(request) + calculateQueueTime(request) + calculateServerLatency(request) + } + + return Array.from(requestsMap.values()).filter(req => req.url && req.method) +} + +export const calculateDurations = (request: HTTPRequest): void => { + if (!request.events.receiveResponse) return + + const responseArgs = request.events.receiveResponse.args as any + const timingData = responseArgs?.data?.timing + + // Find the last ResourceReceivedData event timestamp (this matches Chrome DevTools) + let lastDataTimestamp = 0 + if (request.events.receivedData && request.events.receivedData.length > 0) { + // Use the last ResourceReceivedData event timestamp + lastDataTimestamp = request.events.receivedData[request.events.receivedData.length - 1].ts + } else if (request.events.finishLoading) { + // Fallback to ResourceFinish timestamp if no data events + lastDataTimestamp = request.events.finishLoading.ts + } + + // Chrome DevTools Duration Calculation: + // Duration = Total time from request start to completion (excludes queuing) + // This matches Chrome DevTools "Duration" column in Network tab + + if (timingData?.requestTime && timingData?.sendStart !== undefined && timingData?.receiveHeadersEnd !== undefined) { + // Chrome DevTools standard method: sendStart to receiveHeadersEnd + // This excludes queuing/DNS/connection time and measures actual network time + const networkDurationMs = timingData.receiveHeadersEnd - timingData.sendStart + request.timing.duration = networkDurationMs * 1000 // Convert to microseconds + + // If we have response body data, extend duration to last data chunk + if (lastDataTimestamp) { + const sendStartTimeUs = (timingData.requestTime * 1000000) + (timingData.sendStart * 1000) + const bodyDuration = lastDataTimestamp - sendStartTimeUs + + // Use the longer of header completion or body completion + request.timing.duration = Math.max(request.timing.duration, bodyDuration) + } + + } else if (timingData?.requestTime && timingData?.receiveHeadersEnd !== undefined) { + // Fallback: use receiveHeadersEnd from requestTime (includes all phases) + request.timing.duration = timingData.receiveHeadersEnd * 1000 // Convert to microseconds + + } else if (lastDataTimestamp && timingData?.requestTime && timingData?.sendStart !== undefined) { + // Fallback: sendStart to last data (body download completion) + const sendStartTimeUs = (timingData.requestTime * 1000000) + (timingData.sendStart * 1000) + request.timing.duration = lastDataTimestamp - sendStartTimeUs + + } else if (lastDataTimestamp && timingData?.requestTime) { + // Final fallback: requestTime to last data (includes everything) + const requestTimeUs = timingData.requestTime * 1000000 + request.timing.duration = lastDataTimestamp - requestTimeUs + } + + // Calculate Total Response Time (wall clock time from request initiation to completion) + if (request.events.sendRequest && lastDataTimestamp) { + request.timing.totalResponseTime = lastDataTimestamp - request.events.sendRequest.ts + } else if (request.timing.queueTime && request.timing.duration) { + // Fallback: sum all known components + let totalTime = (request.timing.queueTime || 0) + (request.timing.duration || 0) + + // Add DNS time if available + if (request.timing.dnsStart !== undefined && request.timing.dnsEnd !== undefined && request.timing.dnsStart >= 0) { + totalTime += (request.timing.dnsEnd - request.timing.dnsStart) + } + + // Add connection time if available + if (request.timing.connectStart !== undefined && request.timing.connectEnd !== undefined && request.timing.connectStart >= 0) { + totalTime += (request.timing.connectEnd - request.timing.connectStart) + } + + // Add SSL time if available + if (request.timing.sslStart !== undefined && request.timing.sslEnd !== undefined && request.timing.sslStart >= 0) { + totalTime += (request.timing.sslEnd - request.timing.sslStart) + } + + request.timing.totalResponseTime = totalTime + } + + // Fallback to trace event timestamps if timing data unavailable + if (!request.timing.duration && request.timing.end) { + request.timing.duration = request.timing.end - request.timing.start + } +} + +export const calculateQueueTime = (request: HTTPRequest): void => { + if (!request.events.receiveResponse) return + + const responseArgs = request.events.receiveResponse.args as any + const timingData = responseArgs?.data?.timing + + if (timingData?.requestTime && timingData?.sendStart !== undefined) { + // Chrome DevTools queuing calculation methodology: + // Queuing time = time from request initiation to when network send actually starts + // This includes DNS resolution, connection establishment, and browser queuing + + // requestTime is the baseline timestamp in seconds (when browser initiated the request) + // sendStart is milliseconds offset from requestTime to when sending began + const requestTimeUs = timingData.requestTime * 1000000 + const sendStartUs = timingData.sendStart * 1000 + + // Extract DNS timing + if (timingData.dnsStart >= 0 && timingData.dnsEnd >= 0) { + request.timing.dnsStart = timingData.dnsStart * 1000 // Convert to microseconds + request.timing.dnsEnd = timingData.dnsEnd * 1000 + } + + // Extract connection timing + if (timingData.connectStart >= 0 && timingData.connectEnd >= 0) { + request.timing.connectStart = timingData.connectStart * 1000 // Convert to microseconds + request.timing.connectEnd = timingData.connectEnd * 1000 + } + + // Extract SSL timing if available + if (timingData.sslStart >= 0 && timingData.sslEnd >= 0) { + request.timing.sslStart = timingData.sslStart * 1000 // Convert to microseconds + request.timing.sslEnd = timingData.sslEnd * 1000 + } + + // Extract send/receive timing + if (timingData.sendStart >= 0) { + request.timing.sendStart = timingData.sendStart * 1000 + } + if (timingData.sendEnd >= 0) { + request.timing.sendEnd = timingData.sendEnd * 1000 + } + if (timingData.receiveHeadersStart >= 0) { + request.timing.receiveHeadersStart = timingData.receiveHeadersStart * 1000 + } + if (timingData.receiveHeadersEnd >= 0) { + request.timing.receiveHeadersEnd = timingData.receiveHeadersEnd * 1000 + } + + // Handle the discrepancy between ResourceSendRequest timestamp and timing.requestTime + // This accounts for Chrome-internal delays before network timing begins + if (request.events.sendRequest) { + const resourceSendRequestTs = request.events.sendRequest.ts + const chromeInternalDelay = requestTimeUs - resourceSendRequestTs + + // If there's a significant delay (>1ms) between trace event and timing baseline + if (chromeInternalDelay > 1000) { + // Total queue time = Chrome internal delay + network queue time + request.timing.queueTime = chromeInternalDelay + sendStartUs + } else { + // For more accurate Chrome DevTools matching when no significant delay + if (timingData.dnsStart >= 0 && timingData.connectStart >= 0) { + // If we have detailed timing, isolate just the queuing portion + const dnsStartUs = timingData.dnsStart * 1000 + request.timing.queueTime = dnsStartUs // Pure queuing before DNS + } else if (timingData.connectStart >= 0) { + // If no DNS timing but have connection timing + const connectStartUs = timingData.connectStart * 1000 + request.timing.queueTime = connectStartUs // Queuing before connection + } else { + // Default: use sendStart as queue time + request.timing.queueTime = sendStartUs + } + } + } else { + // Fallback when no ResourceSendRequest event + request.timing.queueTime = sendStartUs + } + + } else if (request.events.willSendRequest && request.events.sendRequest) { + // Legacy fallback: ResourceSendRequest - ResourceWillSendRequest + request.timing.queueTime = request.events.sendRequest.ts - request.events.willSendRequest.ts + } +} + +export const calculateServerLatency = (request: HTTPRequest): void => { + if (!request.events.receiveResponse) return + + const responseArgs = request.events.receiveResponse.args as any + const timingData = responseArgs?.data?.timing + + if (timingData) { + // Chrome DevTools "Waiting (TTFB)" = Time to First Byte + // This is sendEnd to receiveHeadersStart (time server takes to process and respond) + if (timingData.receiveHeadersStart !== undefined && timingData.sendEnd !== undefined) { + // Standard method: sendEnd to receiveHeadersStart (server processing time) + request.timing.serverLatency = (timingData.receiveHeadersStart - timingData.sendEnd) * 1000 // Convert to microseconds + } else if (timingData.receiveHeadersStart !== undefined && timingData.sendStart !== undefined) { + // Fallback: sendStart to receiveHeadersStart (includes send time + server processing) + request.timing.serverLatency = (timingData.receiveHeadersStart - timingData.sendStart) * 1000 + } + + // Ensure non-negative values + if (request.timing.serverLatency && request.timing.serverLatency < 0) { + request.timing.serverLatency = 0 + } + } +} \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/requestPostProcessor.ts b/src/components/httprequestviewer/lib/requestPostProcessor.ts new file mode 100644 index 0000000..dcd3b75 --- /dev/null +++ b/src/components/httprequestviewer/lib/requestPostProcessor.ts @@ -0,0 +1,30 @@ +import type { HTTPRequest } from '../types/httpRequest' +import { HIGHLIGHTING_CONFIG } from './httpRequestConstants' + +export const addRequestPostProcessing = ( + sortedRequests: HTTPRequest[], + analyzeQueueReason: (request: HTTPRequest, allRequests: HTTPRequest[]) => any, + analyzeCDN: (request: HTTPRequest) => any +): HTTPRequest[] => { + if (sortedRequests.length === 0) return sortedRequests + + // Calculate start time offsets from the first request + const firstRequestTime = sortedRequests[0].timing.start + + sortedRequests.forEach(request => { + // Add start time offset + request.timing.startOffset = request.timing.start - firstRequestTime + + // Add queue analysis for requests with significant queue time + if ((request.timing.queueTime || 0) >= HIGHLIGHTING_CONFIG.QUEUE_TIME.ANALYSIS_THRESHOLD) { + request.queueAnalysis = analyzeQueueReason(request, sortedRequests) + } + + // Add CDN analysis for requests with response headers + if (request.responseHeaders && request.responseHeaders.length > 0) { + request.cdnAnalysis = analyzeCDN(request) + } + }) + + return sortedRequests +} \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/screenshotUtils.ts b/src/components/httprequestviewer/lib/screenshotUtils.ts new file mode 100644 index 0000000..7c5c5e0 --- /dev/null +++ b/src/components/httprequestviewer/lib/screenshotUtils.ts @@ -0,0 +1,125 @@ +import ssim from 'ssim.js' +import type { TraceEvent } from '../../../types/trace' +import type { ScreenshotEvent } from '../types/httpRequest' +import { SSIM_SIMILARITY_THRESHOLD } from './httpRequestConstants' + +// Helper function to convert base64 image to ImageData for SSIM analysis +export const base64ToImageData = (base64: string): Promise => { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) { + reject(new Error('Could not get canvas context')) + return + } + + canvas.width = img.width + canvas.height = img.height + ctx.drawImage(img, 0, 0) + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) + resolve(imageData) + } + img.onerror = () => reject(new Error('Failed to load image')) + img.src = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}` + }) +} + +export const extractScreenshots = (traceEvents: TraceEvent[]): ScreenshotEvent[] => { + const screenshots: ScreenshotEvent[] = [] + let index = 0 + + // Debug: Check for any screenshot-related events + const screenshotRelated = traceEvents.filter(event => + event.name.toLowerCase().includes('screenshot') || + event.cat.toLowerCase().includes('screenshot') || + event.name.toLowerCase().includes('snap') || + event.name.toLowerCase().includes('frame') || + event.cat.toLowerCase().includes('devtools') + ) + + console.log('Debug: Screenshot-related events found:', screenshotRelated.length) + if (screenshotRelated.length > 0) { + console.log('Debug: Screenshot-related sample:', screenshotRelated.slice(0, 5).map(e => ({ + name: e.name, + cat: e.cat, + hasArgs: !!e.args, + argsKeys: e.args ? Object.keys(e.args) : [] + }))) + } + + for (const event of traceEvents) { + // Look for screenshot events with more flexible matching + if ((event.name === 'Screenshot' || event.name === 'screenshot') && + (event.cat === 'disabled-by-default-devtools.screenshot' || + event.cat.includes('screenshot') || + event.cat.includes('devtools')) && + event.args) { + const args = event.args as any + + // Check multiple possible locations for screenshot data + const snapshot = args.snapshot || args.data?.snapshot || args.image || args.data?.image + + if (snapshot && typeof snapshot === 'string') { + // Accept both data URLs and base64 strings + if (snapshot.startsWith('data:image/') || snapshot.startsWith('/9j/') || snapshot.length > 1000) { + const screenshotData = snapshot.startsWith('data:image/') ? snapshot : `data:image/jpeg;base64,${snapshot}` + screenshots.push({ + timestamp: event.ts, // This is in microseconds like other trace events + screenshot: screenshotData, + index: index++ + }) + } + } + } + } + + console.log('Debug: Extracted screenshots:', screenshots.length) + return screenshots.sort((a, b) => a.timestamp - b.timestamp) +} + +export const findUniqueScreenshots = async (screenshots: ScreenshotEvent[], threshold: number = SSIM_SIMILARITY_THRESHOLD): Promise => { + if (screenshots.length === 0) return [] + + const uniqueScreenshots: ScreenshotEvent[] = [screenshots[0]] // Always include first screenshot + + console.log('SSIM Analysis: Processing', screenshots.length, 'screenshots') + + // Compare each screenshot with the previous unique one using SSIM + for (let i = 1; i < screenshots.length; i++) { + const current = screenshots[i] + const lastUnique = uniqueScreenshots[uniqueScreenshots.length - 1] + + try { + // Convert both images to ImageData for SSIM analysis + const [currentImageData, lastImageData] = await Promise.all([ + base64ToImageData(current.screenshot), + base64ToImageData(lastUnique.screenshot) + ]) + + // Calculate SSIM similarity (0-1, where 1 is identical) + const similarity = ssim(currentImageData, lastImageData) + + console.log(`SSIM Analysis: Screenshot ${i} similarity: ${similarity.mssim.toFixed(4)}`) + + // If similarity is below threshold, it's different enough to keep + if (similarity.mssim < threshold) { + uniqueScreenshots.push(current) + console.log(`SSIM Analysis: Screenshot ${i} added (significant change detected)`) + } else { + console.log(`SSIM Analysis: Screenshot ${i} filtered out (too similar: ${similarity.mssim.toFixed(4)})`) + } + } catch (error) { + console.warn('SSIM Analysis: Error comparing screenshots, falling back to string comparison:', error) + // Fallback to simple string comparison if SSIM fails + if (current.screenshot !== lastUnique.screenshot) { + uniqueScreenshots.push(current) + } + } + } + + console.log('SSIM Analysis: Filtered from', screenshots.length, 'to', uniqueScreenshots.length, 'unique screenshots') + return uniqueScreenshots +} \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/sortRequests.ts b/src/components/httprequestviewer/lib/sortRequests.ts new file mode 100644 index 0000000..c64a6d6 --- /dev/null +++ b/src/components/httprequestviewer/lib/sortRequests.ts @@ -0,0 +1,87 @@ +import addTimingToRequest from "./addTimingToRequest.ts"; +import type {HTTPRequest} from "../types/httpRequest.ts"; + +export default function sortRequests(httpRequests: HTTPRequest[]): HTTPRequest[] { + return httpRequests + .map(request => { + // Calculate queue time using Chrome DevTools methodology + if (request.events.receiveResponse) { + const responseArgs = request.events.receiveResponse.args as any + const timingData = responseArgs?.data?.timing + + if (timingData?.requestTime && timingData?.sendStart !== undefined) { + const sendStartUs = timingData.sendStart * 1000 + + request.timing.queueTime = sendStartUs // This is queuing + DNS + connection time + addTimingToRequest(request, timingData); + + if (request.events.sendRequest) { + const requestTimeUs = timingData.requestTime * 1000000 + const resourceSendRequestTs = request.events.sendRequest.ts + const chromeInternalDelay = requestTimeUs - resourceSendRequestTs + + // If there's a significant delay (>1ms) between trace event and timing baseline + if (chromeInternalDelay > 1000) { + // Total queue time = Chrome internal delay + network queue time + request.timing.queueTime = chromeInternalDelay + sendStartUs + } else { + // For more accurate Chrome DevTools matching when no significant delay + if (timingData.dnsStart >= 0 && timingData.connectStart >= 0) { + // If we have detailed timing, isolate just the queuing portion + const dnsStartUs = timingData.dnsStart * 1000 + request.timing.queueTime = dnsStartUs // Pure queuing before DNS + } else if (timingData.connectStart >= 0) { + // If no DNS timing but have connection timing + const connectStartUs = timingData.connectStart * 1000 + request.timing.queueTime = connectStartUs // Queuing before connection + } else { + // Default: use sendStart as queue time + request.timing.queueTime = sendStartUs + } + } + } else { + // Fallback when no ResourceSendRequest event + request.timing.queueTime = sendStartUs + } + } else if (request.events.willSendRequest && request.events.sendRequest) { + // Legacy fallback: ResourceSendRequest - ResourceWillSendRequest + request.timing.queueTime = request.events.sendRequest.ts - request.events.willSendRequest.ts + } + } else if (request.events.willSendRequest && request.events.sendRequest) { + // Final fallback when no timing data available + request.timing.queueTime = request.events.sendRequest.ts - request.events.willSendRequest.ts + } + + // Calculate server latency from network timing data - matching Chrome DevTools methodology + if (request.events.receiveResponse) { + const responseArgs = request.events.receiveResponse.args as any + const timingData = responseArgs?.data?.timing + + if (timingData) { + // Chrome DevTools "Waiting (TTFB)" = Time to First Byte + // This is sendEnd to receiveHeadersStart (time server takes to process and respond) + if (timingData.receiveHeadersStart !== undefined && timingData.sendEnd !== undefined) { + // Standard method: sendEnd to receiveHeadersStart (server processing time) + request.timing.serverLatency = (timingData.receiveHeadersStart - timingData.sendEnd) * 1000 // Convert to microseconds + } else if (timingData.receiveHeadersStart !== undefined && timingData.sendStart !== undefined) { + // Fallback: sendStart to receiveHeadersStart (includes send time + server processing) + request.timing.serverLatency = (timingData.receiveHeadersStart - timingData.sendStart) * 1000 + } + + // Ensure non-negative values + if (request.timing.serverLatency && request.timing.serverLatency < 0) { + request.timing.serverLatency = 0 + } + } + } + + // Fallback calculation if detailed timing not available + if (!request.timing.serverLatency && request.timing.duration && request.timing.queueTime) { + // This is an approximation - actual server latency would need more detailed timing + request.timing.serverLatency = Math.max(0, request.timing.duration - request.timing.queueTime) + } + + return request + }) + .sort((a, b) => a.timing.start - b.timing.start); +} \ No newline at end of file diff --git a/src/components/httprequestviewer/lib/urlUtils.ts b/src/components/httprequestviewer/lib/urlUtils.ts new file mode 100644 index 0000000..f4616cf --- /dev/null +++ b/src/components/httprequestviewer/lib/urlUtils.ts @@ -0,0 +1,16 @@ +export const getHostnameFromUrl = (url: string): string => { + try { + const urlObj = new URL(url) + return urlObj.hostname + } catch { + return 'unknown' + } +} + +export const truncateUrl = (url: string, maxLength: number = 60): string => { + if (url.length <= maxLength) { + return url + } + + return url.substring(0, maxLength) + '...' +} \ No newline at end of file diff --git a/src/components/httprequestviewer/types/httpRequest.ts b/src/components/httprequestviewer/types/httpRequest.ts new file mode 100644 index 0000000..f47865a --- /dev/null +++ b/src/components/httprequestviewer/types/httpRequest.ts @@ -0,0 +1,70 @@ +import type { TraceEvent } from '../../../types/trace' + +export interface QueueAnalysis { + reason: 'connection_limit' | 'priority_queue' | 'resource_contention' | 'unknown' + description: string + concurrentRequests: number + connectionId?: number + relatedRequests?: string[] +} + +export interface CDNAnalysis { + provider: 'cloudflare' | 'akamai' | 'aws_cloudfront' | 'fastly' | 'azure_cdn' | 'google_cdn' | 'cdn77' | 'keycdn' | 'unknown' + isEdge: boolean + cacheStatus?: 'hit' | 'miss' | 'expired' | 'bypass' | 'unknown' + edgeLocation?: string + confidence: number // 0-1 confidence score + detectionMethod: string // How we detected it +} + +export interface ScreenshotEvent { + timestamp: number + screenshot: string // base64 image data + index: number +} + +export interface HTTPRequest { + requestId: string + url: string + hostname: string + method: string + resourceType: string + priority: string + statusCode?: number + mimeType?: string + protocol?: string + events: { + willSendRequest?: TraceEvent + sendRequest?: TraceEvent + receiveResponse?: TraceEvent + receivedData?: TraceEvent[] + finishLoading?: TraceEvent + } + timing: { + start: number + startOffset?: number + end?: number + duration?: number + totalResponseTime?: number + queueTime?: number + serverLatency?: number + networkDuration?: number + dnsStart?: number + dnsEnd?: number + connectStart?: number + connectEnd?: number + sslStart?: number + sslEnd?: number + sendStart?: number + sendEnd?: number + receiveHeadersStart?: number + receiveHeadersEnd?: number + } + responseHeaders?: Array<{ name: string, value: string }> + encodedDataLength?: number + contentLength?: number + fromCache: boolean + connectionReused: boolean + queueAnalysis?: QueueAnalysis + cdnAnalysis?: CDNAnalysis +} \ No newline at end of file diff --git a/src/types/trace.ts b/src/types/trace.ts new file mode 100644 index 0000000..7b87f3f --- /dev/null +++ b/src/types/trace.ts @@ -0,0 +1,20 @@ +export interface TraceEvent { + name: string + cat: string + ts: number + pid?: number + tid?: number + ph?: string + dur?: number + args?: any + [key: string]: any +} + +export type TraceEventPhase = 'M' | 'X' | 'I' | 'B' | 'E' | 'D' | 'b' | 'e' | 'n' | string + +export interface TraceFile { + metadata: { + [key: string]: any + } + traceEvents: TraceEvent[] +} \ No newline at end of file