Compare commits

...

2 Commits

Author SHA1 Message Date
ec91cfbafd Refactored code to use css and be more modular. 2025-08-08 13:21:17 -05:00
8a791a1186 Implement comprehensive 3D timeline visualization with enhanced features
- Create new BabylonTimelineViewer with swimlane-based layout
- Add dual-box system: gray server time (50% opacity) + blue network time boxes
- Implement yellow queue time visualization with 25% opacity
- Add host-based swimlanes with alternating left/right positioning sorted by earliest request time
- Create timeline grid lines with adaptive time labels (microseconds/milliseconds/seconds)
- Add UniversalCamera with WASD keyboard navigation from behind timeline (z: -10)
- Implement vertical gradient coloring for stacked overlapping requests
- Extract reusable timeline label creation function
- Position hostname labels below ground level (y: -1) for cleaner visualization
- Support both 3D Network View (radial) and 3D Timeline View (swimlanes) as modal overlays
- Add SSIM.js integration for intelligent screenshot similarity analysis
- Enhance CDN detection with comprehensive Akamai patterns and improved accuracy
- Add server latency calculation and color-coded display
- Add content-length header extraction and color-coded display
- Move 3D viewer from main nav to HTTP requests page with modal interface

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 09:21:45 -05:00
33 changed files with 6224 additions and 1558 deletions

View File

@ -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
- **API Verification**: Before suggesting any Babylon.js code, verify method signatures and availability in the current version
-

578
analysis.css Normal file
View File

@ -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;
}

707
optimized.css Normal file
View File

@ -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;
}

9
package-lock.json generated
View File

@ -10,7 +10,8 @@
"dependencies": {
"babylonjs": "^8.21.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"ssim.js": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
@ -3041,6 +3042,12 @@
"node": ">=0.10.0"
}
},
"node_modules/ssim.js": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/ssim.js/-/ssim.js-3.5.0.tgz",
"integrity": "sha512-Aj6Jl2z6oDmgYFFbQqK7fght19bXdOxY7Tj03nF+03M9gCBAjeIiO8/PlEGMfKDwYpw4q6iBqVq2YuREorGg/g==",
"license": "MIT"
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",

View File

@ -12,7 +12,8 @@
"dependencies": {
"babylonjs": "^8.21.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"ssim.js": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",

View File

@ -1,14 +1,15 @@
import { useState, useEffect } from 'react'
import './App.css'
import BabylonViewer from './BabylonViewer'
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 = '3d' | 'trace' | 'phases' | 'http'
type AppView = 'trace' | 'phases' | 'http' | 'debug'
type AppMode = 'selector' | 'upload' | 'analysis'
function App() {
@ -17,6 +18,9 @@ function App() {
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(null)
const [hasTraces, setHasTraces] = useState<boolean>(false)
const [dbInitialized, setDbInitialized] = useState(false)
// Always call hooks at the top level
const { traceData } = useDatabaseTraceData(selectedTraceId)
useEffect(() => {
initializeApp()
@ -123,20 +127,6 @@ function App() {
</div>
<nav style={{ display: 'flex', gap: '10px' }}>
<button
onClick={() => setCurrentView('3d')}
style={{
background: currentView === '3d' ? '#007bff' : '#6c757d',
color: 'white',
border: 'none',
padding: '8px 16px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
3D Viewer
</button>
<button
onClick={() => setCurrentView('trace')}
style={{
@ -179,15 +169,23 @@ function App() {
>
HTTP Requests
</button>
<button
onClick={() => setCurrentView('debug')}
style={{
background: currentView === 'debug' ? '#007bff' : '#6c757d',
color: 'white',
border: 'none',
padding: '8px 16px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Request Debug
</button>
</nav>
</div>
{currentView === '3d' && (
<div style={{ width: '100%', height: '600px' }}>
<BabylonViewer />
</div>
)}
{currentView === 'trace' && (
<TraceViewer traceId={selectedTraceId} />
)}
@ -199,6 +197,10 @@ function App() {
{currentView === 'http' && (
<HTTPRequestViewer traceId={selectedTraceId} />
)}
{currentView === 'debug' && traceData && (
<RequestDebugger traceEvents={traceData.traceEvents} />
)}
</div>
</>
)

View File

@ -0,0 +1,514 @@
import { useEffect, useRef } from 'react'
import {
Engine,
Scene,
UniversalCamera,
Vector3,
HemisphericLight,
MeshBuilder,
StandardMaterial,
Color3,
DynamicTexture
} from 'babylonjs'
// Helper function to create timeline labels
function createTimelineLabel(
scene: Scene,
labelId: string,
position: Vector3,
actualTimeOffset: number
): void {
// Calculate time conversions
const actualTimeMs = actualTimeOffset / 1000 // Convert to milliseconds
const actualTimeSeconds = actualTimeOffset / 1000000 // Convert to seconds
// Format time label based on magnitude (assuming microseconds input)
let timeLabelText = ''
if (actualTimeSeconds >= 1) {
// If >= 1 second, show in seconds
timeLabelText = `${actualTimeSeconds.toFixed(1)}s`
} else if (actualTimeMs >= 1) {
// If >= 1 millisecond, show in milliseconds
timeLabelText = `${Math.round(actualTimeMs)}ms`
} else {
// If < 1 millisecond, show in microseconds
timeLabelText = `${Math.round(actualTimeOffset)}μs`
}
// 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.9)', true)
// Create label plane
const timeLabelPlane = MeshBuilder.CreatePlane(`timeLabelPlane_${labelId}`, { size: 0.5 }, scene)
timeLabelPlane.position = position
// Create and apply material
const timeLabelMaterial = new StandardMaterial(`timeLabelMaterial_${labelId}`, scene)
timeLabelMaterial.diffuseTexture = labelTexture
timeLabelMaterial.hasAlpha = true
timeLabelMaterial.backFaceCulling = false
timeLabelPlane.material = timeLabelMaterial
}
interface HTTPRequest {
requestId: string
url: string
hostname: string
method: string
resourceType: string
priority: string
statusCode?: number
mimeType?: string
protocol?: string
timing: {
start: number
startOffset?: number
end?: number
duration?: number
queueTime?: number
serverLatency?: number
}
encodedDataLength?: number
contentLength?: number
fromCache: boolean
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 = [], screenshots = [] }: BabylonTimelineViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<Engine | null>(null)
const sceneRef = useRef<Scene | null>(null)
useEffect(() => {
if (!canvasRef.current) return
const canvas = canvasRef.current
const engine = new Engine(canvas, true)
engineRef.current = engine
const scene = new Scene(engine)
sceneRef.current = scene
// Create universal camera for enhanced keyboard and mouse navigation
const camera = new UniversalCamera('camera1', new Vector3(0, 5, -10), scene)
camera.attachControl(canvas, true)
// Set camera target to look at the origin
camera.setTarget(new Vector3(0, 0, 0))
// Configure camera movement speed and sensitivity
camera.speed = 0.5
camera.angularSensibility = 2000
// Enable WASD keys explicitly
camera.keysUp = [87] // W key
camera.keysDown = [83] // S key
camera.keysLeft = [65] // A key
camera.keysRight = [68] // D key
scene.activeCamera = camera
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)
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)
const startMaterial = new StandardMaterial('startMaterial', scene)
startMaterial.diffuseColor = new Color3(0.2, 0.8, 0.2) // Green for start
startMaterial.specularColor = new Color3(0.5, 1, 0.5)
startMarker.material = startMaterial
// 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
if (httpRequests && httpRequests.length > 0) {
// Group requests by hostname
const requestsByHostname = new Map<string, HTTPRequest[]>()
httpRequests.forEach(request => {
if (!requestsByHostname.has(request.hostname)) {
requestsByHostname.set(request.hostname, [])
}
requestsByHostname.get(request.hostname)!.push(request)
})
// Find min and max start times for Z-axis timeline normalization
const startTimes = httpRequests.map(req => req.timing.startOffset || 0)
const endTimes = httpRequests.map(req => (req.timing.startOffset || 0) + (req.timing.duration || 0))
const minStartTime = Math.min(...startTimes)
const maxEndTime = Math.max(...endTimes)
const totalTimeRange = maxEndTime - minStartTime
// Debug: Check the actual timing values (assuming microseconds)
console.log(`Timeline debug: minStart=${minStartTime}μs, maxEnd=${maxEndTime}μs, totalRange=${totalTimeRange}μs`)
console.log(`Timeline debug: totalRange in ms=${totalTimeRange/1000}ms, in seconds=${totalTimeRange/1000000}s`)
console.log(`Sample request timing:`, httpRequests[0]?.timing)
// Find min and max content-length values for height normalization
const contentLengths = httpRequests
.map(req => req.contentLength || 0)
.filter(size => size > 0) // Only consider requests with content-length
const minContentLength = contentLengths.length > 0 ? Math.min(...contentLengths) : 1
const maxContentLength = contentLengths.length > 0 ? Math.max(...contentLengths) : 1
const contentLengthRange = maxContentLength - minContentLength
const hostnames = Array.from(requestsByHostname.keys())
// Sort hostnames by their earliest request start time
const hostnamesWithStartTimes = hostnames.map(hostname => {
const requests = requestsByHostname.get(hostname)!
const earliestStartTime = Math.min(...requests.map(req => req.timing.startOffset || 0))
return { hostname, earliestStartTime }
})
hostnamesWithStartTimes.sort((a, b) => a.earliestStartTime - b.earliestStartTime)
const hostCount = hostnames.length
const minZ = 0 // Timeline start at Z = 0
const maxZ = 20 // Timeline end at Z = 10
const minHeight = 0.1 // Minimum box height (0.1 meters)
const maxHeight = 2 // Maximum box height (5 meters)
const swimlaneSpacing = 1.5 // Spacing between swimlanes on X-axis
// Calculate the full width needed to span all swimlanes
const totalWidth = (hostCount - 1) * swimlaneSpacing + 4 // Add padding on both sides
// Create timeline grid lines at regular intervals across the normalized 0-10 timeline
const gridLineCount = 10 // Create 10 grid lines across the timeline
for (let i = 0; i <= gridLineCount; i++) {
const zPosition = minZ + (i / gridLineCount) * (maxZ - minZ) // Evenly spaced from 0 to 10
// Calculate the actual time this position represents
const actualTimeOffset = (i / gridLineCount) * totalTimeRange // This is in microseconds
const actualTimeMs = actualTimeOffset / 1000 // Convert to milliseconds
const actualTimeSeconds = actualTimeOffset / 1000000 // Convert to seconds
// Debug: Let's see what the actual values are
console.log(`Grid line ${i}: offset=${actualTimeOffset}μs, ms=${actualTimeMs}ms, seconds=${actualTimeSeconds}s, totalRange=${totalTimeRange}μs`)
// Create grid line spanning the full width of all content
const linePoints = [
new Vector3(-totalWidth / 2, 0, zPosition),
new Vector3(totalWidth / 2, 0, zPosition)
]
const gridLine = MeshBuilder.CreateLines(`gridLine_${i}`, { points: linePoints }, scene)
const gridMaterial = new StandardMaterial(`gridMaterial_${i}`, scene)
gridMaterial.diffuseColor = new Color3(0.5, 0.5, 0.5)
gridMaterial.alpha = 0.3
// Add timeline label for each grid line
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.
let xPosition = 0
if (sortedIndex > 0) {
const distance = Math.ceil(sortedIndex / 2) * swimlaneSpacing
xPosition = (sortedIndex % 2 === 1) ? -distance : distance
}
// Create hostname label at the start of each swimlane
const labelTexture = new DynamicTexture(`hostLabel_${sortedIndex}`, { width: 256, height: 64 }, scene)
labelTexture.hasAlpha = true
labelTexture.drawText(
hostname,
null, null,
'16px Arial',
'white',
'rgba(0,0,0,0.7)',
true
)
const hostLabel = MeshBuilder.CreatePlane(`hostLabelPlane_${sortedIndex}`, { size: 1.5 }, scene)
hostLabel.position = new Vector3(xPosition, -1, -1) // Position at Y = -1, Z = -1 (below ground, before timeline start)
const labelMaterial = new StandardMaterial(`hostLabelMaterial_${sortedIndex}`, scene)
labelMaterial.diffuseTexture = labelTexture
labelMaterial.hasAlpha = true
labelMaterial.backFaceCulling = false
hostLabel.material = labelMaterial
// Get requests for this hostname and sort by start time
const hostnameRequests = requestsByHostname.get(hostname)!
hostnameRequests.sort((a, b) => (a.timing.startOffset || 0) - (b.timing.startOffset || 0))
// Track placed boxes for this hostname to handle stacking
const placedBoxes: Array<{
startZ: number
endZ: number
height: number
yPosition: number
}> = []
// Create boxes for each request from this hostname
hostnameRequests.forEach((request, requestIndex) => {
// Calculate Z positions based on timeline (start time to end time)
const startTime = request.timing.startOffset || 0
const duration = request.timing.duration || 1000 // Default 1ms if no duration
const endTime = startTime + duration
// Normalize to Z-axis range (0 to 10)
const normalizedStartTime = totalTimeRange > 0 ? (startTime - minStartTime) / totalTimeRange : 0
const normalizedEndTime = totalTimeRange > 0 ? (endTime - minStartTime) / totalTimeRange : 0
const startZ = minZ + (normalizedStartTime * (maxZ - minZ))
const endZ = minZ + (normalizedEndTime * (maxZ - minZ))
// Calculate box depth (duration) and center Z position
const boxDepth = Math.max(0.05, endZ - startZ) // Minimum depth of 0.05m
const centerZ = startZ + (boxDepth / 2)
// Calculate height based on content-length
const contentLength = request.contentLength || 0
let boxHeight = minHeight
if (contentLength > 0 && contentLengthRange > 0) {
const normalizedSize = (contentLength - minContentLength) / contentLengthRange
boxHeight = minHeight + (normalizedSize * (maxHeight - minHeight))
}
// Find the highest Y position for overlapping boxes in this swimlane
let stackHeight = 0
for (const placedBox of placedBoxes) {
// Check if this request overlaps with a placed box on the timeline (Z-axis)
if (startZ < placedBox.endZ && endZ > placedBox.startZ) {
// This box overlaps, so we need to stack it above
stackHeight = Math.max(stackHeight, placedBox.yPosition)
}
}
// Calculate Y position (center of the box) - stack height is the bottom of where this box should be
const yPosition = stackHeight + (boxHeight / 2)
// Calculate server latency and network time portions
const serverLatency = request.timing.serverLatency || 0
const networkTime = duration - serverLatency
// Calculate proportional depths for server vs network time
const serverLatencyRatio = serverLatency > 0 ? serverLatency / duration : 0
const networkTimeRatio = networkTime > 0 ? networkTime / duration : 1
const serverDepth = boxDepth * serverLatencyRatio
const networkDepth = boxDepth * networkTimeRatio
// Calculate color based on vertical position (same gradient for both boxes)
const verticalColorIntensity = Math.min(1.0, Math.max(0.3, (stackHeight + boxHeight) / 8.0))
// Create server time box (gray) - positioned at the start of the request
if (serverDepth > 0.01) { // Only create if server time is significant
const serverCenterZ = startZ + (serverDepth / 2)
const serverBox = MeshBuilder.CreateBox(`serverBox_${sortedIndex}_${requestIndex}`, {
width: 0.2,
height: boxHeight,
depth: serverDepth
}, scene)
serverBox.position = new Vector3(xPosition, yPosition, serverCenterZ)
// Gray material with gradient and 50% opacity
const serverMaterial = new StandardMaterial(`serverMaterial_${sortedIndex}_${requestIndex}`, scene)
serverMaterial.diffuseColor = new Color3(verticalColorIntensity * 0.5, verticalColorIntensity * 0.5, verticalColorIntensity * 0.5)
serverMaterial.alpha = 0.5 // 50% opacity for server time boxes
if (request.fromCache) {
serverMaterial.alpha = 0.35 // Reduce further for cached requests (0.5 * 0.7)
}
serverBox.material = serverMaterial
}
// Create network time box (blue) - positioned after server time
const networkStartZ = startZ + serverDepth
const networkCenterZ = networkStartZ + (networkDepth / 2)
const networkBox = MeshBuilder.CreateBox(`networkBox_${sortedIndex}_${requestIndex}`, {
width: 0.2,
height: boxHeight,
depth: networkDepth
}, scene)
networkBox.position = new Vector3(xPosition, yPosition, networkCenterZ)
// Add this box to the placed boxes list for future overlap checks
placedBoxes.push({
startZ,
endZ,
height: boxHeight,
yPosition: stackHeight + boxHeight
})
// Blue material with gradient and status code coloring
const networkMaterial = new StandardMaterial(`networkMaterial_${sortedIndex}_${requestIndex}`, scene)
if (request.statusCode) {
if (request.statusCode >= 200 && request.statusCode < 300) {
// Blue with gradient for success
networkMaterial.diffuseColor = new Color3(0, 0, verticalColorIntensity)
} else if (request.statusCode >= 300 && request.statusCode < 400) {
networkMaterial.diffuseColor = new Color3(1, 1, 0) // Yellow for redirects
} else if (request.statusCode >= 400 && request.statusCode < 500) {
networkMaterial.diffuseColor = new Color3(1, 0.5, 0) // Orange for client errors
} else if (request.statusCode >= 500) {
networkMaterial.diffuseColor = new Color3(1, 0, 0) // Red for server errors
}
} else {
networkMaterial.diffuseColor = new Color3(0, 0, 0.5) // Dark blue for unknown
}
// Add transparency for cached requests
if (request.fromCache) {
networkMaterial.alpha = 0.7
}
networkBox.material = networkMaterial
// Add yellow queue time line if queue time exists
if (request.timing.queueTime && request.timing.queueTime > 0) {
// Calculate queue start time (when request was first queued - before the actual request start)
const queueStartTime = startTime - request.timing.queueTime
const queueEndTime = startTime // Queue ends when actual request processing starts
// Normalize queue start and end times to Z-axis
const normalizedQueueStartTime = totalTimeRange > 0 ? Math.max(0, (queueStartTime - minStartTime) / totalTimeRange) : 0
const normalizedQueueEndTime = totalTimeRange > 0 ? (queueEndTime - minStartTime) / totalTimeRange : 0
const queueStartZ = minZ + (normalizedQueueStartTime * (maxZ - minZ))
const queueEndZ = minZ + (normalizedQueueEndTime * (maxZ - minZ))
// Calculate queue line depth (from queue start to queue end)
const queueLineDepth = Math.max(0.01, queueEndZ - queueStartZ) // Minimum depth of 0.01m
const queueLineCenterZ = queueStartZ + (queueLineDepth / 2)
// Create yellow queue time line positioned slightly above the request box
const queueLine = MeshBuilder.CreateBox(`queueLine_${sortedIndex}_${requestIndex}`, {
width: 0.05, // Thin line width
height: 0.05, // Thin line height
depth: queueLineDepth
}, scene)
// Position queue line at the same vertical center as the request box
queueLine.position = new Vector3(xPosition, yPosition, queueLineCenterZ)
// Create yellow material for queue time with 25% opacity
const queueMaterial = new StandardMaterial(`queueMaterial_${sortedIndex}_${requestIndex}`, scene)
queueMaterial.diffuseColor = new Color3(1, 1, 0) // Yellow color
queueMaterial.emissiveColor = new Color3(0.2, 0.2, 0) // Slight glow
queueMaterial.alpha = 0.25 // 25% opacity for queue time boxes
queueLine.material = queueMaterial
}
})
// Create a swimlane line from timeline start to end for visual reference
const swimlaneLinePoints = [new Vector3(xPosition, 0, minZ), new Vector3(xPosition, 0, maxZ)]
const swimlaneLine = MeshBuilder.CreateLines(`swimlaneLine_${sortedIndex}`, { points: swimlaneLinePoints }, scene)
const lineMaterial = new StandardMaterial(`swimlaneLineMaterial_${sortedIndex}`, scene)
lineMaterial.diffuseColor = new Color3(0.4, 0.4, 0.4)
lineMaterial.alpha = 0.3
})
}
engine.runRenderLoop(() => {
scene.render()
})
const handleResize = () => {
engine.resize()
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
engine.dispose()
}
}, [httpRequests, screenshots])
return (
<canvas
ref={canvasRef}
width={width}
height={height}
style={{ width: '100%', height: '100%', display: 'block' }}
/>
)
}

View File

@ -7,15 +7,42 @@ import {
HemisphericLight,
MeshBuilder,
StandardMaterial,
Color3
Color3,
Mesh,
DynamicTexture
} from 'babylonjs'
interface HTTPRequest {
requestId: string
url: string
hostname: string
method: string
resourceType: string
priority: string
statusCode?: number
mimeType?: string
protocol?: string
timing: {
start: number
startOffset?: number
end?: number
duration?: number
queueTime?: number
serverLatency?: number
}
encodedDataLength?: number
contentLength?: number
fromCache: boolean
connectionReused: boolean
}
interface BabylonViewerProps {
width?: number
height?: number
httpRequests?: HTTPRequest[]
}
export default function BabylonViewer({ width = 800, height = 600 }: BabylonViewerProps) {
export default function BabylonViewer({ width = 800, height = 600, httpRequests = [] }: BabylonViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<Engine | null>(null)
const sceneRef = useRef<Scene | null>(null)
@ -30,26 +57,217 @@ export default function BabylonViewer({ width = 800, height = 600 }: BabylonView
const scene = new Scene(engine)
sceneRef.current = scene
const camera = new ArcRotateCamera('camera1', -Math.PI / 2, Math.PI / 2.5, 10, Vector3.Zero(), scene)
const camera = new ArcRotateCamera('camera1', -Math.PI / 2, Math.PI / 2.5, 25, Vector3.Zero(), scene)
camera.attachControl(canvas, true)
scene.activeCamera = camera
const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene)
light.intensity = 0.7
const sphere = MeshBuilder.CreateSphere('sphere', { diameter: 2 }, scene)
sphere.position.y = 1
const ground = MeshBuilder.CreateGround('ground', { width: 6, height: 6 }, scene)
const sphereMaterial = new StandardMaterial('sphereMaterial', scene)
sphereMaterial.diffuseColor = new Color3(1, 0, 1)
sphere.material = sphereMaterial
// Create ground plane for reference
const ground = MeshBuilder.CreateGround('ground', { width: 50, height: 30 }, scene)
const groundMaterial = new StandardMaterial('groundMaterial', scene)
groundMaterial.diffuseColor = new Color3(0.5, 0.5, 0.5)
groundMaterial.diffuseColor = new Color3(0.2, 0.2, 0.2)
groundMaterial.alpha = 0.3
ground.material = groundMaterial
// Create central sphere at origin
const centralSphere = MeshBuilder.CreateSphere('centralSphere', { diameter: 0.5 }, scene)
centralSphere.position = Vector3.Zero()
const sphereMaterial = new StandardMaterial('sphereMaterial', scene)
sphereMaterial.diffuseColor = new Color3(0.3, 0.3, 0.8) // Blue sphere
sphereMaterial.specularColor = new Color3(0.5, 0.5, 1)
centralSphere.material = sphereMaterial
// Visualize HTTP requests as 3D objects arranged radially by hostname
if (httpRequests && httpRequests.length > 0) {
// Group requests by hostname
const requestsByHostname = new Map<string, HTTPRequest[]>()
httpRequests.forEach(request => {
if (!requestsByHostname.has(request.hostname)) {
requestsByHostname.set(request.hostname, [])
}
requestsByHostname.get(request.hostname)!.push(request)
})
// Find min and max start times for normalization
const startTimes = httpRequests.map(req => req.timing.startOffset || 0)
const minStartTime = Math.min(...startTimes)
const maxStartTime = Math.max(...startTimes)
const timeRange = maxStartTime - minStartTime
// Find min and max content-length values for height normalization
const contentLengths = httpRequests
.map(req => req.contentLength || 0)
.filter(size => size > 0) // Only consider requests with content-length
const minContentLength = contentLengths.length > 0 ? Math.min(...contentLengths) : 1
const maxContentLength = contentLengths.length > 0 ? Math.max(...contentLengths) : 1
const contentLengthRange = maxContentLength - minContentLength
// Find min and max duration values for depth normalization
const durations = httpRequests
.map(req => req.timing.duration || 0)
.filter(duration => duration > 0)
const minDuration = durations.length > 0 ? Math.min(...durations) : 1000
const maxDuration = durations.length > 0 ? Math.max(...durations) : 1000
const durationRange = maxDuration - minDuration
const hostnames = Array.from(requestsByHostname.keys())
const hostCount = hostnames.length
const labelRadius = 12 // Distance from center for hostname labels
const minDistance = 1 // Minimum distance from center (1 meter)
const maxDistance = 10 // Maximum distance from center (10 meters)
const minHeight = 0.1 // Minimum box height (0.1 meters)
const maxHeight = 5 // Maximum box height (5 meters)
const minDepth = 0.05 // Minimum box depth (0.05 meters)
const maxDepth = 2 // Maximum box depth (2 meters)
hostnames.forEach((hostname, hostIndex) => {
// Calculate radial position for this hostname
const angle = (hostIndex / hostCount) * 2 * Math.PI
const labelX = labelRadius * Math.cos(angle)
const labelZ = labelRadius * Math.sin(angle)
// Create hostname label that always faces camera
const labelTexture = new DynamicTexture(`hostLabel_${hostIndex}`, { width: 256, height: 64 }, scene)
labelTexture.hasAlpha = true
labelTexture.drawText(
hostname,
null, null,
'16px Arial',
'white',
'rgba(0,0,0,0.7)',
true
)
const hostLabel = MeshBuilder.CreatePlane(`hostLabelPlane_${hostIndex}`, { size: 1.5 }, scene)
hostLabel.position = new Vector3(labelX, 1.5, labelZ)
// Rotate label to face origin on vertical axis only, then add 180 degree rotation
const labelDirectionToOrigin = Vector3.Zero().subtract(hostLabel.position).normalize()
const labelYRotation = Math.atan2(labelDirectionToOrigin.x, labelDirectionToOrigin.z)
hostLabel.rotation.y = labelYRotation + Math.PI // Add 180 degrees (π radians)
const labelMaterial = new StandardMaterial(`hostLabelMaterial_${hostIndex}`, scene)
labelMaterial.diffuseTexture = labelTexture
labelMaterial.hasAlpha = true
labelMaterial.backFaceCulling = false
hostLabel.material = labelMaterial
// Get requests for this hostname and sort by start time
const hostnameRequests = requestsByHostname.get(hostname)!
hostnameRequests.sort((a, b) => (a.timing.startOffset || 0) - (b.timing.startOffset || 0))
// Track placed boxes for this hostname to handle stacking
const placedBoxes: Array<{
startDistance: number
endDistance: number
height: number
yPosition: number
}> = []
// Create boxes for each request from this hostname
hostnameRequests.forEach((request, requestIndex) => {
// Calculate start and end distances from center based on timing
const startTime = request.timing.startOffset || 0
const duration = request.timing.duration || 1000 // Default 1ms if no duration
const endTime = startTime + duration
const normalizedStartTime = timeRange > 0 ? (startTime - minStartTime) / timeRange : 0
const normalizedEndTime = timeRange > 0 ? (endTime - minStartTime) / timeRange : 0
const startDistance = minDistance + (normalizedStartTime * (maxDistance - minDistance))
const endDistance = minDistance + (normalizedEndTime * (maxDistance - minDistance))
// Calculate box depth and center position
const boxDepth = Math.max(minDepth, endDistance - startDistance)
const centerDistance = startDistance + (boxDepth / 2)
// Position box at center distance along the hostname's angle
const boxX = centerDistance * Math.cos(angle)
const boxZ = centerDistance * Math.sin(angle)
// Calculate height based on content-length
const contentLength = request.contentLength || 0
let boxHeight = minHeight
if (contentLength > 0 && contentLengthRange > 0) {
const normalizedSize = (contentLength - minContentLength) / contentLengthRange
boxHeight = minHeight + (normalizedSize * (maxHeight - minHeight))
}
// Find the highest Y position for overlapping boxes
let stackHeight = 0
for (const placedBox of placedBoxes) {
// Check if this request overlaps with a placed box
if (startDistance < placedBox.endDistance && endDistance > placedBox.startDistance) {
// This box overlaps, so we need to stack it above
stackHeight = Math.max(stackHeight, placedBox.yPosition)
}
}
// Calculate Y position (center of the box) - stack height is the bottom of where this box should be
const yPosition = stackHeight + (boxHeight / 2)
// Create box with duration-based depth and content-length based height
const box = MeshBuilder.CreateBox(`request_${hostIndex}_${requestIndex}`, {
width: 0.1,
height: boxHeight,
depth: boxDepth
}, scene)
box.position = new Vector3(boxX, yPosition, boxZ)
// Add this box to the placed boxes list for future overlap checks
// yPosition here represents the top of this box for future stacking
placedBoxes.push({
startDistance,
endDistance,
height: boxHeight,
yPosition: stackHeight + boxHeight
})
// Rotate box to face the origin only on the vertical (Y) axis
const directionToOrigin = Vector3.Zero().subtract(box.position).normalize()
const yRotation = Math.atan2(directionToOrigin.x, directionToOrigin.z)
box.rotation.y = yRotation
// Calculate color based on vertical position for success requests
// Higher boxes are lighter (closer to white), lower boxes are darker
const verticalColorIntensity = Math.min(1.0, Math.max(0.3, (stackHeight + boxHeight) / 8.0))
// Color based on status code
const material = new StandardMaterial(`material_${hostIndex}_${requestIndex}`, scene)
if (request.statusCode) {
if (request.statusCode >= 200 && request.statusCode < 300) {
material.diffuseColor = new Color3(verticalColorIntensity, verticalColorIntensity, verticalColorIntensity)
} else if (request.statusCode >= 300 && request.statusCode < 400) {
material.diffuseColor = new Color3(1, 1, 0) // Yellow for redirects
} else if (request.statusCode >= 400 && request.statusCode < 500) {
material.diffuseColor = new Color3(1, 0.5, 0) // Orange for client errors
} else if (request.statusCode >= 500) {
material.diffuseColor = new Color3(1, 0, 0) // Red for server errors
}
} else {
material.diffuseColor = new Color3(0.5, 0.5, 0.5) // Gray for unknown
}
// Add transparency for cached requests
if (request.fromCache) {
material.alpha = 0.7
}
box.material = material
})
// Create a line from center to hostname label position for visual connection
const linePoints = [Vector3.Zero(), new Vector3(labelX * 0.8, 0, labelZ * 0.8)]
const line = MeshBuilder.CreateLines(`connectionLine_${hostIndex}`, { points: linePoints }, scene)
const lineMaterial = new StandardMaterial(`lineMaterial_${hostIndex}`, scene)
lineMaterial.diffuseColor = new Color3(0.4, 0.4, 0.4)
lineMaterial.alpha = 0.5
})
}
engine.runRenderLoop(() => {
scene.render()
})
@ -64,7 +282,7 @@ export default function BabylonViewer({ width = 800, height = 600 }: BabylonView
window.removeEventListener('resize', handleResize)
engine.dispose()
}
}, [])
}, [httpRequests])
return (
<canvas

File diff suppressed because it is too large Load Diff

View File

@ -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<string>('')
const [searchUrl, setSearchUrl] = useState<string>('')
// Extract all unique request IDs and URLs
const requestData = useMemo(() => {
const requests = new Map<string, { id: string; url: string; events: HTTPRequestEvents }>()
const urlToRequestId = new Map<string, string[]>()
// 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 (
<div style={{ fontSize: '12px', fontFamily: 'monospace', marginTop: '10px' }}>
<strong>Network Timing (from ResourceReceiveResponse):</strong>
<div style={{ marginLeft: '10px', marginTop: '5px' }}>
<div><strong>requestTime:</strong> {timing.requestTime} seconds</div>
<div><strong>dnsStart:</strong> {timing.dnsStart} ms</div>
<div><strong>dnsEnd:</strong> {timing.dnsEnd} ms</div>
<div><strong>connectStart:</strong> {timing.connectStart} ms</div>
<div><strong>connectEnd:</strong> {timing.connectEnd} ms</div>
<div><strong>sslStart:</strong> {timing.sslStart} ms</div>
<div><strong>sslEnd:</strong> {timing.sslEnd} ms</div>
<div><strong>sendStart:</strong> {timing.sendStart} ms</div>
<div><strong>sendEnd:</strong> {timing.sendEnd} ms</div>
<div><strong>receiveHeadersStart:</strong> {timing.receiveHeadersStart} ms</div>
<div><strong>receiveHeadersEnd:</strong> {timing.receiveHeadersEnd} ms</div>
</div>
</div>
)
}
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 (
<div style={{
backgroundColor: '#f8f9fa',
padding: '15px',
borderRadius: '5px',
marginTop: '15px',
fontSize: '13px'
}}>
<strong>Duration Calculations:</strong>
{timing && (
<div style={{ marginTop: '10px' }}>
<div><strong>Chrome DevTools Method (sendStart to receiveHeadersEnd):</strong></div>
<div style={{ marginLeft: '10px', color: '#007bff' }}>
{timing.receiveHeadersEnd - timing.sendStart} ms
</div>
</div>
)}
{timing && timing.connectStart >= 0 && lastDataEvent && (
<div style={{ marginTop: '10px' }}>
<div><strong>Our Method - New Connection (connectStart to last data):</strong></div>
<div style={{ marginLeft: '10px', color: '#28a745' }}>
{(lastDataEvent.ts - (timing.requestTime * 1000000 + timing.connectStart * 1000)) / 1000} ms
</div>
</div>
)}
{timing && timing.connectStart < 0 && lastDataEvent && (
<div style={{ marginTop: '10px' }}>
<div><strong>Our Method - Reused Connection (sendStart to last data):</strong></div>
<div style={{ marginLeft: '10px', color: '#ffc107' }}>
{(lastDataEvent.ts - (timing.requestTime * 1000000 + timing.sendStart * 1000)) / 1000} ms
</div>
</div>
)}
{timing && (
<div style={{ marginTop: '10px' }}>
<div><strong>Fallback Method (receiveHeadersEnd):</strong></div>
<div style={{ marginLeft: '10px', color: '#dc3545' }}>
{timing.receiveHeadersEnd} ms
</div>
</div>
)}
<div style={{ marginTop: '15px', fontSize: '12px', color: '#666' }}>
<strong>Trace Event Timestamps:</strong>
<div>Send Request: {formatTimestamp(events.sendRequest.ts)}</div>
<div>Receive Response: {formatTimestamp(events.receiveResponse.ts)}</div>
{lastDataEvent && <div>Last Data Chunk: {formatTimestamp(lastDataEvent.ts)}</div>}
{events.finishLoading && <div>Finish Loading: {formatTimestamp(events.finishLoading.ts)}</div>}
</div>
</div>
)
}
return (
<div style={{ padding: '20px', fontFamily: 'system-ui, sans-serif' }}>
<h2>Request Timeline Debugger</h2>
{/* Search Controls */}
<div style={{
backgroundColor: '#f8f9fa',
padding: '20px',
borderRadius: '8px',
marginBottom: '20px'
}}>
<div style={{ marginBottom: '15px' }}>
<label style={{ fontWeight: 'bold', display: 'block', marginBottom: '5px' }}>
Search by URL or Request ID:
</label>
<input
type="text"
placeholder="e.g., rum-standalone.js or 5254.2"
value={searchUrl}
onChange={(e) => setSearchUrl(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #ced4da',
borderRadius: '4px',
fontSize: '14px',
width: '400px'
}}
/>
</div>
<div>
<label style={{ fontWeight: 'bold', display: 'block', marginBottom: '5px' }}>
Select Request:
</label>
<select
value={selectedRequestId}
onChange={(e) => setSelectedRequestId(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #ced4da',
borderRadius: '4px',
fontSize: '14px',
width: '100%',
maxWidth: '800px'
}}
>
<option value="">-- Select a request to debug --</option>
{filteredRequests.map(req => (
<option key={req.id} value={req.id}>
{req.id} - {req.url.length > 80 ? req.url.substring(0, 80) + '...' : req.url}
</option>
))}
</select>
</div>
</div>
{selectedRequest && (
<div style={{
backgroundColor: 'white',
border: '1px solid #dee2e6',
borderRadius: '8px',
padding: '20px'
}}>
<h3>Request Details: {selectedRequest.id}</h3>
<div style={{ marginBottom: '15px', wordBreak: 'break-all', fontSize: '14px' }}>
<strong>URL:</strong> {selectedRequest.url}
</div>
{/* Duration Calculations */}
{calculateDurations(selectedRequest.events)}
{/* Event Details */}
<div style={{ marginTop: '30px' }}>
<h4>All Events for this Request:</h4>
{(() => {
const baseTimestamp = selectedRequest.events.sendRequest?.ts
return (
<>
{selectedRequest.events.sendRequest && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#e3f2fd', borderRadius: '4px' }}>
<strong>ResourceSendRequest</strong> - {formatTimestamp(selectedRequest.events.sendRequest.ts, baseTimestamp)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.sendRequest.args, null, 2)}
</pre>
</div>
)}
{selectedRequest.events.receiveResponse && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#e8f5e8', borderRadius: '4px' }}>
<strong>ResourceReceiveResponse</strong> - {formatTimestamp(selectedRequest.events.receiveResponse.ts, baseTimestamp)}
{formatTiming((selectedRequest.events.receiveResponse.args as any)?.data?.timing)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.receiveResponse.args, null, 2)}
</pre>
</div>
)}
{selectedRequest.events.receivedData.map((event, index) => (
<div key={index} style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#fff3e0', borderRadius: '4px' }}>
<strong>ResourceReceivedData #{index + 1}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
{index === selectedRequest.events.receivedData.length - 1 && (
<span style={{ color: '#ff9800', fontWeight: 'bold', marginLeft: '10px' }}> LAST DATA CHUNK</span>
)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
{selectedRequest.events.finishLoading && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#fce4ec', borderRadius: '4px' }}>
<strong>ResourceFinish</strong> - {formatTimestamp(selectedRequest.events.finishLoading.ts, baseTimestamp)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.finishLoading.args, null, 2)}
</pre>
</div>
)}
{/* Additional Event Types */}
{selectedRequest.events.parseOnBackground.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>V8 Parse Events:</h5>
{selectedRequest.events.parseOnBackground.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#e1f5fe', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
{(event as any).dur && <span style={{ marginLeft: '10px', color: '#0277bd' }}>Duration: {((event as any).dur / 1000).toFixed(2)}ms</span>}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{selectedRequest.events.compile.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>V8 Compile Events:</h5>
{selectedRequest.events.compile.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#f3e5f5', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
{(event as any).dur && <span style={{ marginLeft: '10px', color: '#7b1fa2' }}>Duration: {((event as any).dur / 1000).toFixed(2)}ms</span>}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{selectedRequest.events.evaluateScript.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>Script Evaluation Events:</h5>
{selectedRequest.events.evaluateScript.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#e8f5e8', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
{(event as any).dur && <span style={{ marginLeft: '10px', color: '#2e7d32' }}>Duration: {((event as any).dur / 1000).toFixed(2)}ms</span>}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{/* Additional Network Events */}
{selectedRequest.events.willSendRequest && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#fff8e1', borderRadius: '4px' }}>
<strong>ResourceWillSendRequest</strong> - {formatTimestamp(selectedRequest.events.willSendRequest.ts, baseTimestamp)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.willSendRequest.args, null, 2)}
</pre>
</div>
)}
{selectedRequest.events.didReceiveResponse && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#f1f8e9', borderRadius: '4px' }}>
<strong>ResourceDidReceiveResponse</strong> - {formatTimestamp(selectedRequest.events.didReceiveResponse.ts, baseTimestamp)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.didReceiveResponse.args, null, 2)}
</pre>
</div>
)}
{selectedRequest.events.didFinishLoading && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#f9fbe7', borderRadius: '4px' }}>
<strong>ResourceDidFinishLoading</strong> - {formatTimestamp(selectedRequest.events.didFinishLoading.ts, baseTimestamp)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.didFinishLoading.args, null, 2)}
</pre>
</div>
)}
{selectedRequest.events.resourceChangedPriority && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#fff3e0', borderRadius: '4px' }}>
<strong>ResourceChangedPriority</strong> - {formatTimestamp(selectedRequest.events.resourceChangedPriority.ts, baseTimestamp)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.resourceChangedPriority.args, null, 2)}
</pre>
</div>
)}
{selectedRequest.events.resourceChangePriority && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#fff3e0', borderRadius: '4px' }}>
<strong>ResourceChangePriority</strong> - {formatTimestamp(selectedRequest.events.resourceChangePriority.ts, baseTimestamp)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.resourceChangePriority.args, null, 2)}
</pre>
</div>
)}
{selectedRequest.events.resourceMarkAsCached && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#e8f5e8', borderRadius: '4px' }}>
<strong>ResourceMarkAsCached</strong> - {formatTimestamp(selectedRequest.events.resourceMarkAsCached.ts, baseTimestamp)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.resourceMarkAsCached.args, null, 2)}
</pre>
</div>
)}
{selectedRequest.events.preloadRenderBlockingStatusChange && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#fce4ec', borderRadius: '4px' }}>
<strong>PreloadRenderBlockingStatusChange</strong> - {formatTimestamp(selectedRequest.events.preloadRenderBlockingStatusChange.ts, baseTimestamp)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.preloadRenderBlockingStatusChange.args, null, 2)}
</pre>
</div>
)}
{/* Navigation Events */}
{selectedRequest.events.navigationRequest && selectedRequest.events.navigationRequest.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>Navigation Request Events:</h5>
{selectedRequest.events.navigationRequest.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#e3f2fd', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
{(event as any).dur && <span style={{ marginLeft: '10px', color: '#0277bd' }}>Duration: {((event as any).dur / 1000).toFixed(2)}ms</span>}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{selectedRequest.events.willStartRequest && selectedRequest.events.willStartRequest.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>Will Start Request Events:</h5>
{selectedRequest.events.willStartRequest.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#fff8e1', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{selectedRequest.events.willProcessResponse && selectedRequest.events.willProcessResponse.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>Will Process Response Events:</h5>
{selectedRequest.events.willProcessResponse.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#f1f8e9', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{selectedRequest.events.didCommitNavigation && (
<div style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#e8f5e8', borderRadius: '4px' }}>
<strong>DidCommitNavigation</strong> - {formatTimestamp(selectedRequest.events.didCommitNavigation.ts, baseTimestamp)}
<pre style={{ fontSize: '12px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(selectedRequest.events.didCommitNavigation.args, null, 2)}
</pre>
</div>
)}
{/* URL Loader Events */}
{selectedRequest.events.throttlingURLLoader && selectedRequest.events.throttlingURLLoader.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>Throttling URL Loader Events ({selectedRequest.events.throttlingURLLoader.length}):</h5>
{selectedRequest.events.throttlingURLLoader.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#fff3e0', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
{(event as any).dur && <span style={{ marginLeft: '10px', color: '#f57c00' }}>Duration: {((event as any).dur / 1000).toFixed(2)}ms</span>}
<div style={{ fontSize: '11px', color: '#666', marginTop: '5px' }}>Category: {event.cat}</div>
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{selectedRequest.events.onReceiveResponse && selectedRequest.events.onReceiveResponse.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>URL Loader OnReceiveResponse Events:</h5>
{selectedRequest.events.onReceiveResponse.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#e8f5e8', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{selectedRequest.events.onRequestComplete && selectedRequest.events.onRequestComplete.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>URL Loader OnRequestComplete Events:</h5>
{selectedRequest.events.onRequestComplete.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#fce4ec', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{selectedRequest.events.onReceivedRedirect && selectedRequest.events.onReceivedRedirect.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>Redirect Events:</h5>
{selectedRequest.events.onReceivedRedirect.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#fff8e1', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{/* Connection Events */}
{selectedRequest.events.connection && selectedRequest.events.connection.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>Connection Events:</h5>
{selectedRequest.events.connection.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#e1f5fe', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
{(event as any).dur && <span style={{ marginLeft: '10px', color: '#0277bd' }}>Duration: {((event as any).dur / 1000).toFixed(2)}ms</span>}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{selectedRequest.events.keepAliveURLLoader && selectedRequest.events.keepAliveURLLoader.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>Keep-Alive URL Loader Events:</h5>
{selectedRequest.events.keepAliveURLLoader.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#f3e5f5', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{selectedRequest.events.other.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h5>Other Related Events ({selectedRequest.events.other.length}):</h5>
{selectedRequest.events.other.map((event, index) => (
<div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<strong>{event.name}</strong> - {formatTimestamp(event.ts, baseTimestamp)}
{(event as any).dur && <span style={{ marginLeft: '10px', color: '#666' }}>Duration: {((event as any).dur / 1000).toFixed(2)}ms</span>}
<div style={{ fontSize: '11px', color: '#666', marginTop: '5px' }}>Category: {event.cat}</div>
<pre style={{ fontSize: '11px', marginTop: '10px', overflow: 'auto' }}>
{JSON.stringify(event.args, null, 2)}
</pre>
</div>
))}
</div>
)}
</>
)
})()}
</div>
</div>
)}
</div>
)
}

View File

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

View File

@ -0,0 +1,13 @@
import styles from './HTTPRequestLoading.module.css';
export default function HTTPRequestLoading() {
return (<>
<div className={styles.headerDiv}>
<div className={styles.headerContent}/>
<div>Loading HTTP requests...</div>
</div>
<div className={styles.headerDiv}>
<div className={styles.headerContent}></div>
<div>Loading HTTP requests...</div>
</div>
</>);
}

View File

@ -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;
}

View File

@ -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<string>('all')
const [protocolFilter, setProtocolFilter] = useState<string>('all')
const [hostnameFilter, setHostnameFilter] = useState<string>('all')
const [priorityFilter, setPriorityFilter] = useState<string>('all')
const [showQueueAnalysis, setShowQueueAnalysis] = useState(false)
const [showScreenshots, setShowScreenshots] = useState(false)
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<Set<string>>(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<ScreenshotEvent[]>([])
const [screenshotsLoading, setScreenshotsLoading] = useState(false)
useEffect(() => {
if (!traceData) {
setScreenshots([])
return
}
const processScreenshots = async () => {
setScreenshotsLoading(true)
try {
const allScreenshots = extractScreenshots(traceData.traceEvents)
console.log('Debug: Found screenshots:', allScreenshots.length)
console.log('Debug: Screenshot events sample:', allScreenshots.slice(0, 3))
const uniqueScreenshots = await findUniqueScreenshots(allScreenshots, ssimThreshold)
console.log('Debug: Unique screenshots after SSIM analysis:', uniqueScreenshots.length)
setScreenshots(uniqueScreenshots)
} catch (error) {
console.error('Error processing screenshots with SSIM:', error)
// Fallback to extracting all screenshots without SSIM filtering
const allScreenshots = extractScreenshots(traceData.traceEvents)
setScreenshots(allScreenshots)
} finally {
setScreenshotsLoading(false)
}
}
processScreenshots()
}, [traceData, ssimThreshold])
const filteredRequests = useMemo(() => {
let requests = httpRequests
// Filter by resource type
if (resourceTypeFilter !== 'all') {
requests = requests.filter(req => req.resourceType === resourceTypeFilter)
}
// Filter by protocol
if (protocolFilter !== 'all') {
requests = requests.filter(req => req.protocol === protocolFilter)
}
// Filter by hostname
if (hostnameFilter !== 'all') {
requests = requests.filter(req => req.hostname === hostnameFilter)
}
// Filter by priority
if (priorityFilter !== 'all') {
requests = requests.filter(req => req.priority === priorityFilter)
}
// Filter by search term
if (searchTerm) {
const term = searchTerm.toLowerCase()
requests = requests.filter(req =>
req.url.toLowerCase().includes(term) ||
req.method.toLowerCase().includes(term) ||
req.resourceType.toLowerCase().includes(term) ||
req.hostname.toLowerCase().includes(term) ||
req.statusCode?.toString().includes(term)
)
}
return requests
}, [httpRequests, resourceTypeFilter, protocolFilter, hostnameFilter, priorityFilter, searchTerm])
const paginatedRequests = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
return filteredRequests.slice(startIndex, startIndex + ITEMS_PER_PAGE)
}, [filteredRequests, currentPage])
// Create timeline entries that include both HTTP requests and screenshot changes
const timelineEntries = useMemo(() => {
const entries: Array<{
type: 'request' | 'screenshot',
timestamp: number,
data: HTTPRequest | ScreenshotEvent
}> = []
// Add HTTP requests
filteredRequests.forEach(request => {
entries.push({
type: 'request',
timestamp: request.timing.start,
data: request
})
})
// Add screenshots if enabled
if (showScreenshots && screenshots.length > 0) {
screenshots.forEach(screenshot => {
entries.push({
type: 'screenshot',
timestamp: screenshot.timestamp,
data: screenshot
})
})
}
// Sort by timestamp
return entries.sort((a, b) => a.timestamp - b.timestamp)
}, [filteredRequests, screenshots, showScreenshots])
const paginatedTimelineEntries = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
return timelineEntries.slice(startIndex, startIndex + ITEMS_PER_PAGE)
}, [timelineEntries, currentPage])
const resourceTypes = useMemo(() => {
const types = new Set(httpRequests.map(req => req.resourceType))
return Array.from(types).sort()
}, [httpRequests])
const protocols = useMemo(() => {
const protos = new Set(httpRequests.map(req => req.protocol).filter((p): p is string => Boolean(p)))
return Array.from(protos).sort()
}, [httpRequests])
const hostnames = useMemo(() => {
const hosts = new Set(httpRequests.map(req => req.hostname).filter(h => h !== 'unknown'))
return Array.from(hosts).sort()
}, [httpRequests])
const priorities = useMemo(() => {
const prios = new Set(httpRequests.map(req => req.priority).filter(Boolean))
return Array.from(prios).sort((a, b) => {
// Sort priorities by importance: VeryHigh, High, Medium, Low, VeryLow
const order = ['VeryHigh', 'High', 'Medium', 'Low', 'VeryLow']
return order.indexOf(a) - order.indexOf(b)
})
}, [httpRequests])
const totalPages = Math.ceil((showScreenshots ? timelineEntries.length : filteredRequests.length) / ITEMS_PER_PAGE)
const toggleRowExpansion = (requestId: string) => {
const newExpanded = new Set(expandedRows)
if (newExpanded.has(requestId)) {
newExpanded.delete(requestId)
} else {
newExpanded.add(requestId)
}
setExpandedRows(newExpanded)
}
if (loading) {
return (
<HTTPRequestLoading/>
)
}
if (error) {
return (
<div className={styles.errorContainer}>
<div className={styles.errorMessage}>
<h3>Error Loading Trace Data</h3>
<p>{error}</p>
</div>
</div>
)
}
return (
<div className={styles.container}>
<h2>HTTP Requests & Responses</h2>
{/* Controls */}
<RequestFilters
httpRequests={httpRequests}
screenshots={screenshots}
screenshotsLoading={screenshotsLoading}
resourceTypes={resourceTypes}
protocols={protocols}
hostnames={hostnames}
priorities={priorities}
filteredRequests={filteredRequests}
timelineEntries={timelineEntries}
resourceTypeFilter={resourceTypeFilter}
protocolFilter={protocolFilter}
hostnameFilter={hostnameFilter}
priorityFilter={priorityFilter}
searchTerm={searchTerm}
showQueueAnalysis={showQueueAnalysis}
showScreenshots={showScreenshots}
show3DViewer={show3DViewer}
showTimelineViewer={showTimelineViewer}
pendingSSIMThreshold={pendingSSIMThreshold}
ssimThreshold={ssimThreshold}
setResourceTypeFilter={setResourceTypeFilter}
setProtocolFilter={setProtocolFilter}
setHostnameFilter={setHostnameFilter}
setPriorityFilter={setPriorityFilter}
setSearchTerm={setSearchTerm}
setCurrentPage={setCurrentPage}
setShowQueueAnalysis={setShowQueueAnalysis}
setShowScreenshots={setShowScreenshots}
setShow3DViewer={setShow3DViewer}
setShowTimelineViewer={setShowTimelineViewer}
setPendingSSIMThreshold={setPendingSSIMThreshold}
handleSSIMRecalculate={handleSSIMRecalculate}
/>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className={styles.paginationControls}>
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={styles.paginationButton}
>
Previous
</button>
<span className={styles.paginationInfo}>
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={styles.paginationButton}
>
Next
</button>
</div>
)}
{/* 3D Network Visualization Modal */}
{show3DViewer && (
<div className={styles.modalOverlay}>
<div className={styles.modalContainer}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>
3D Network Visualization ({filteredRequests.length} requests)
</h3>
<button
onClick={() => setShow3DViewer(false)}
className={styles.modalCloseButton}
>
Close
</button>
</div>
<div className={styles.modalContent}>
<BabylonViewer httpRequests={filteredRequests} width={window.innerWidth * 0.9} height={window.innerHeight * 0.9 - 100} />
</div>
<div className={styles.modalLegend}>
<div><strong>Legend:</strong></div>
<div> Gray gradient: 2xx Success (darker=lower, lighter=higher)</div>
<div>🟡 Yellow: 3xx Redirects</div>
<div>🟠 Orange: 4xx Client Errors</div>
<div>🔴 Red: 5xx Server Errors</div>
<div><strong>Layout:</strong></div>
<div>🔵 Central sphere: Origin</div>
<div>🏷 Hostname labels: At 12m radius</div>
<div>📦 Request boxes: Start end timeline</div>
<div>📏 Front face: Request start time</div>
<div>📐 Height: 0.1m-5m (content-length)</div>
<div>📊 Depth: Request duration</div>
<div>📚 Overlapping requests stack vertically</div>
<div>🔗 Connection lines to center</div>
<div>👁 Labels always face camera</div>
</div>
</div>
</div>
)}
{/* 3D Timeline Visualization Modal */}
{showTimelineViewer && (
<div className={styles.modalOverlay}>
<div className={styles.modalContainer}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>
3D Timeline Visualization ({filteredRequests.length} requests)
</h3>
<button
onClick={() => setShowTimelineViewer(false)}
className={styles.modalCloseButton}
>
Close
</button>
</div>
<div className={styles.modalContent}>
<BabylonTimelineViewer httpRequests={filteredRequests} screenshots={screenshots} width={window.innerWidth * 0.9} height={window.innerHeight * 0.9 - 100} />
</div>
<div className={styles.modalLegend}>
<div><strong>Legend:</strong></div>
<div> Gray gradient: 2xx Success (darker=lower, lighter=higher)</div>
<div>🟡 Yellow: 3xx Redirects</div>
<div>🟠 Orange: 4xx Client Errors</div>
<div>🔴 Red: 5xx Server Errors</div>
<div><strong>Timeline Layout:</strong></div>
<div>🔵 Central sphere: Timeline origin</div>
<div>🏷 Hostname labels: At 12m radius</div>
<div>📦 Request boxes: Chronological timeline</div>
<div>📏 Distance from center: Start time</div>
<div>📐 Height: 0.1m-5m (content-length)</div>
<div>📊 Depth: Request duration</div>
<div>📚 Overlapping requests stack vertically</div>
<div>🔗 Connection lines to center</div>
<div>👁 Labels face origin (180° rotated)</div>
</div>
</div>
</div>
)}
{/* Requests Table */}
<RequestsTable
httpRequests={httpRequests}
showScreenshots={showScreenshots}
paginatedTimelineEntries={paginatedTimelineEntries}
paginatedRequests={paginatedRequests}
showQueueAnalysis={showQueueAnalysis}
expandedRows={expandedRows}
onToggleRowExpansion={toggleRowExpansion}
/>
</div>
)
}

View File

@ -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;
}

View File

@ -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<RequestFiltersProps> = ({
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 (
<div className={styles.filtersContainer}>
{/* Resource Type Filter */}
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Resource Type:</label>
<select
value={resourceTypeFilter}
onChange={(e) => {
setResourceTypeFilter(e.target.value)
setCurrentPage(1)
}}
className={styles.resourceTypeSelect}
>
<option value="all">All Types ({httpRequests.length})</option>
{resourceTypes.map(type => (
<option key={type} value={type}>
{type} ({httpRequests.filter(r => r.resourceType === type).length})
</option>
))}
</select>
</div>
{/* Protocol Filter */}
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Protocol:</label>
<select
value={protocolFilter}
onChange={(e) => {
setProtocolFilter(e.target.value)
setCurrentPage(1)
}}
className={styles.protocolSelect}
>
<option value="all">All Protocols</option>
{protocols.map(protocol => (
<option key={protocol} value={protocol}>
{protocol} ({httpRequests.filter(r => r.protocol === protocol).length})
</option>
))}
</select>
</div>
{/* Hostname Filter */}
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Hostname:</label>
<select
value={hostnameFilter}
onChange={(e) => {
setHostnameFilter(e.target.value)
setCurrentPage(1)
}}
className={styles.hostnameSelect}
>
<option value="all">All Hostnames</option>
{hostnames.map(hostname => (
<option key={hostname} value={hostname}>
{hostname} ({httpRequests.filter(r => r.hostname === hostname).length})
</option>
))}
</select>
</div>
{/* Priority Filter */}
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Priority:</label>
<select
value={priorityFilter}
onChange={(e) => {
setPriorityFilter(e.target.value)
setCurrentPage(1)
}}
className={styles.prioritySelect}
>
<option value="all">All Priorities</option>
{priorities.map(priority => (
<option key={priority} value={priority}>
{priority} ({httpRequests.filter(r => r.priority === priority).length})
</option>
))}
</select>
</div>
{/* Search */}
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Search:</label>
<input
type="text"
placeholder="Search URL, method, type, or status..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setCurrentPage(1)
}}
className={styles.searchInput}
/>
</div>
{/* Queue Analysis Toggle */}
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Queue Analysis:</label>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={showQueueAnalysis}
onChange={(e) => setShowQueueAnalysis(e.target.checked)}
className={styles.checkbox}
/>
Show queue analysis
</label>
</div>
{/* 3D Viewer Toggle */}
{httpRequests.length > 0 && (
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>3D Visualization:</label>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={show3DViewer}
onChange={(e) => setShow3DViewer(e.target.checked)}
className={styles.checkbox}
/>
Show 3D Network View
</label>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={showTimelineViewer}
onChange={(e) => setShowTimelineViewer(e.target.checked)}
className={styles.checkbox}
/>
Show 3D Timeline View
</label>
</div>
)}
{/* Screenshots Toggle */}
{(screenshots.length > 0 || screenshotsLoading) && (
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Filmstrip:</label>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={showScreenshots}
onChange={(e) => setShowScreenshots(e.target.checked)}
className={styles.checkbox}
disabled={screenshotsLoading}
/>
{screenshotsLoading ? (
<span className={styles.screenshotLoadingText}>
🔍 Analyzing screenshots with SSIM...
</span>
) : (
`Show screenshots (${screenshots.length} unique frames)`
)}
</label>
{screenshots.length > 0 && !screenshotsLoading && (
<div className={styles.ssimControls}>
<div className={styles.ssimControlsRow}>
<label className={styles.ssimLabel}>
<span>SSIM Similarity Threshold:</span>
<input
type="range"
min="0.8"
max="0.99"
step="0.01"
value={pendingSSIMThreshold}
onChange={(e) => setPendingSSIMThreshold(parseFloat(e.target.value))}
className={styles.ssimSlider}
/>
<span>{pendingSSIMThreshold.toFixed(2)}</span>
</label>
<button
onClick={handleSSIMRecalculate}
className={styles.recalculateButton}
title="Recalculate SSIM similarity with new threshold"
>
Recalculate
</button>
{ssimThreshold !== pendingSSIMThreshold && (
<span className={styles.ssimPendingText}>
(Click Recalculate to apply)
</span>
)}
</div>
<div className={styles.ssimHelpText}>
Lower values = more screenshots kept (more sensitive to changes)
</div>
</div>
)}
</div>
)}
{/* Results count */}
<div className={styles.resultsCounter}>
{showScreenshots ?
`${timelineEntries.length.toLocaleString()} timeline entries` :
`${filteredRequests.length.toLocaleString()} requests found`}
</div>
</div>
)
}
export default RequestFilters

View File

@ -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<RequestRowDetailsProps> = ({ request }) => {
return (
<tr key={`${request.requestId}-expanded`} className={styles.expandedRow}>
<td colSpan={18}>
<div className={styles.expandedContent}>
{/* Request Details */}
<div className={styles.detailCard}>
<h4 className={styles.detailCardTitle}>Request Details</h4>
<div className={styles.detailList}>
<div className={styles.detailListItem}><strong>Request ID:</strong> {request.requestId}</div>
<div className={styles.detailListItem}><strong>Method:</strong> {request.method}</div>
<div className={styles.detailListItem}><strong>Priority:</strong> {request.priority}</div>
<div className={styles.detailListItem}><strong>MIME Type:</strong> {request.mimeType || '-'}</div>
<div className={styles.detailListItem}><strong>Content-Length:</strong> {request.contentLength ? formatSize(request.contentLength) : '-'}</div>
<div className={styles.detailListItem}><strong>From Cache:</strong> {request.fromCache ? 'Yes' : 'No'}</div>
<div className={styles.detailListItem}><strong>Connection Reused:</strong> {request.connectionReused ? 'Yes' : 'No'}</div>
</div>
</div>
{/* Network Timing */}
<div className={styles.detailCard}>
<h4 className={styles.detailCardTitle}>Network Timing</h4>
<div className={styles.detailList}>
<div className={styles.detailListItem}><strong>Start Time:</strong> {formatDuration(request.timing.startOffset)}</div>
<div className={`${styles.detailListItem} ${styles.queueTimeContainer}`}>
<strong>Queue Time:</strong> {formatDuration(request.timing.queueTime)}
{request.queueAnalysis && (
<span title={request.queueAnalysis.description} className={styles.queueAnalysisIcon}>
{getQueueAnalysisIcon(request.queueAnalysis)}
</span>
)}
</div>
<div className={styles.detailListItem}><strong>Server Latency:</strong> {formatDuration(request.timing.serverLatency)}</div>
<div className={styles.detailListItem}><strong>Network Duration:</strong> {formatDuration(request.timing.duration)}</div>
<div className={`${styles.detailListItem} ${styles.timingHighlighted}`} style={{ ...getTotalResponseTimeColor(request.timing.totalResponseTime) }}>
<strong>Total Response Time:</strong> {formatDuration(request.timing.totalResponseTime)}
</div>
<div className={styles.detailListItem}><strong>Network Duration Only:</strong> {formatDuration(request.timing.networkDuration)}</div>
{/* DNS Timing */}
<div className={styles.detailListItem}>
<strong>DNS:</strong> {
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))
: <span className={styles.connectionCached}>cached</span>
}
</div>
{/* Connection Timing */}
<div className={styles.detailListItem}>
<strong>Connection:</strong> {
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))
: <span className={styles.connectionReused}>
{request.connectionReused ? 'reused' : 'cached'}
</span>
}
</div>
{/* SSL Timing (only show if HTTPS and timing available) */}
{(request.url.startsWith('https://') || request.protocol === 'h2') && (
<div className={styles.detailListItem}>
<strong>SSL:</strong> {
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))
: <span className={styles.connectionReused}>reused</span>
}
</div>
)}
{/* Send Timing */}
{request.timing.sendStart !== undefined && request.timing.sendEnd !== undefined && request.timing.sendStart >= 0 && request.timing.sendEnd >= 0 && (
<div className={styles.detailListItem}><strong>Send:</strong> {formatDuration((request.timing.sendEnd || 0) - (request.timing.sendStart || 0))}</div>
)}
{/* Receive Headers Timing */}
{request.timing.receiveHeadersStart !== undefined && request.timing.receiveHeadersEnd !== undefined && request.timing.receiveHeadersStart >= 0 && request.timing.receiveHeadersEnd >= 0 && (
<div className={styles.detailListItem}><strong>Receive Headers:</strong> {formatDuration((request.timing.receiveHeadersEnd || 0) - (request.timing.receiveHeadersStart || 0))}</div>
)}
</div>
</div>
{/* Queue Analysis */}
{request.queueAnalysis && request.queueAnalysis.reason !== 'unknown' && (
<div className={`${styles.detailCard} ${styles.queueAnalysisCard}`}>
<h4 className={styles.detailCardTitle}>
Queue Analysis {getQueueAnalysisIcon(request.queueAnalysis)}
</h4>
<div className={styles.detailList}>
<div className={styles.detailListItem}><strong>Reason:</strong> {request.queueAnalysis.description}</div>
<div className={styles.detailListItem}><strong>Concurrent Requests:</strong> {request.queueAnalysis.concurrentRequests}</div>
{request.queueAnalysis.relatedRequests && request.queueAnalysis.relatedRequests.length > 0 && (
<div className={styles.detailListItem}>
<strong>Related Request IDs:</strong>{' '}
<span className={styles.relatedRequestIds}>
{request.queueAnalysis.relatedRequests.join(', ')}
{request.queueAnalysis.concurrentRequests > request.queueAnalysis.relatedRequests.length &&
` (+${request.queueAnalysis.concurrentRequests - request.queueAnalysis.relatedRequests.length} more)`}
</span>
</div>
)}
</div>
</div>
)}
{/* CDN Analysis */}
{request.cdnAnalysis && request.cdnAnalysis.provider !== 'unknown' && (
<div className={`${styles.detailCard} ${styles.cdnAnalysisCard}`}>
<h4 className={styles.detailCardTitle}>
CDN Analysis {getCDNIcon(request.cdnAnalysis)}
</h4>
<div className={styles.detailList}>
<div className={styles.detailListItem}><strong>Provider:</strong> {getCDNDisplayName(request.cdnAnalysis.provider)}</div>
<div className={styles.detailListItem}><strong>Source:</strong> {request.cdnAnalysis.isEdge ? 'Edge Server' : 'Origin Server'}</div>
{request.cdnAnalysis.cacheStatus && request.cdnAnalysis.cacheStatus !== 'unknown' && (
<div className={styles.detailListItem}><strong>Cache Status:</strong> {request.cdnAnalysis.cacheStatus.toUpperCase()}</div>
)}
{request.cdnAnalysis.edgeLocation && (
<div className={styles.detailListItem}><strong>Edge Location:</strong> {request.cdnAnalysis.edgeLocation}</div>
)}
<div className={styles.detailListItem}><strong>Confidence:</strong> {(request.cdnAnalysis.confidence * 100).toFixed(0)}%</div>
<div className={styles.detailListItem}><strong>Detection Method:</strong> {request.cdnAnalysis.detectionMethod}</div>
{/* Debug info for canadiantire.ca requests */}
{request.hostname.includes('canadiantire.ca') && (
<div className={styles.debugSection}>
<div className={styles.debugTitle}>
Debug - CDN Detection Analysis:
</div>
<div className={styles.debugInfo}>
<strong>Current Detection:</strong> {request.cdnAnalysis?.provider}
(confidence: {((request.cdnAnalysis?.confidence || 0) * 100).toFixed(0)}%)
</div>
<div className={styles.debugHeaders}>
All CDN-Related Headers:
</div>
{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 (
<div key={idx} className={`${styles.headerLine} ${isAkamaiIndicator ? styles.akamaiIndicator : ''}`}>
<span className={`${styles.headerName} ${isAkamaiIndicator ? styles.akamaiIndicator : ''}`}>
{header.name}:
</span> {header.value}
{isAkamaiIndicator && (
<span className={styles.akamaiLabel}>
AKAMAI
</span>
)}
</div>
)
}
return null
})}
</div>
)}
</div>
</div>
)}
{/* Response Headers */}
{request.responseHeaders && request.responseHeaders.length > 0 && (
<div className={`${styles.detailCard} ${styles.fullWidth}`}>
<h4 className={styles.detailCardTitle}>
Response Headers ({request.responseHeaders.length})
</h4>
<div className={styles.headersContainer}>
{request.responseHeaders.map((header, index) => (
<div key={index} className={styles.headerItem}>
<div className={styles.headerItemName}>
{header.name}:
</div>
<div className={styles.headerItemValue}>
{header.value}
</div>
</div>
))}
</div>
</div>
)}
</div>
</td>
</tr>
)
}
export default RequestRowDetails

View File

@ -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<RequestRowSummaryProps> = ({
request,
showQueueAnalysis,
isExpanded,
onToggleRowExpansion
}) => {
return (
<>
<tr key={request.requestId} className={styles.tableRow}
onClick={() => onToggleRowExpansion(request.requestId)}
>
<td className={`${styles.tableCell} ${styles.expandCell}`}>
{isExpanded ? '' : '+'}
</td>
<td className={`${styles.tableCell} ${styles.methodCell} ${request.method === 'GET' ? styles.methodGet : styles.methodOther}`}>
{request.method}
</td>
<td className={`${styles.tableCell} ${styles.statusCell}`} style={{ color: getStatusColor(request.statusCode) }}>
{request.statusCode || '-'}
</td>
<td className={`${styles.tableCell} ${styles.gray}`}>
{request.resourceType}
</td>
<td className={`${styles.tableCell} ${styles.priorityCell}`} style={{ color: getPriorityColor(request.priority) }}>
{request.priority || '-'}
</td>
<td className={`${styles.tableCell} ${styles.timeCell}`}>
{formatDuration(request.timing.startOffset)}
</td>
<td className={`${styles.tableCell} ${styles.timeCell}`}
style={{
color: request.timing.queueTime && request.timing.queueTime > 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'
}}>
<div className={styles.queueTimeContainer}>
{formatDuration(request.timing.queueTime)}
{showQueueAnalysis && request.queueAnalysis && (
<span
title={request.queueAnalysis.description}
className={styles.queueAnalysisIcon}
>
{getQueueAnalysisIcon(request.queueAnalysis)}
</span>
)}
</div>
</td>
{/* DNS Time */}
<td className={`${styles.tableCell} ${styles.timeCell}`}>
{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))
: <span className={styles.connectionCached}>cached</span>
}
</td>
{/* Connection Time */}
<td className={`${styles.tableCell} ${styles.timeCell}`}>
{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))
: <span className={styles.connectionReused}>
{request.connectionReused ? 'reused' : 'cached'}
</span>
}
</td>
<td className={styles.serverLatencyCell} style={{ ...getServerLatencyColor(request.timing.serverLatency) }}>
{formatDuration(request.timing.serverLatency)}
</td>
<td className={`${styles.tableCell} ${styles.urlCell}`}>
<a href={request.url} target="_blank" rel="noopener noreferrer" className={styles.urlLink}>
{truncateUrl(request.url)}
</a>
</td>
<td className={styles.durationCell} style={{ ...getDurationColor(request.timing.duration) }}>
{formatDuration(request.timing.duration)}
</td>
{/* Total Response Time */}
<td className={styles.totalResponseTimeCell} style={{ ...getTotalResponseTimeColor(request.timing.totalResponseTime) }}>
{formatDuration(request.timing.totalResponseTime)}
</td>
<td className={styles.sizeCell} style={{ ...getSizeColor(request.encodedDataLength) }}>
{formatSize(request.encodedDataLength)}
</td>
<td className={styles.sizeCell} style={{ ...getSizeColor(request.contentLength) }}>
{request.contentLength ? formatSize(request.contentLength) : '-'}
</td>
<td className={styles.protocolCell} style={{ color: getProtocolColor(request.protocol) }}>
{request.protocol || '-'}
</td>
<td className={`${styles.cdnCell} ${request.cdnAnalysis ? '' : styles.default}`}
title={request.cdnAnalysis ?
`${getCDNDisplayName(request.cdnAnalysis.provider)} ${request.cdnAnalysis.isEdge ? '(Edge)' : '(Origin)'} - ${request.cdnAnalysis.detectionMethod}` :
'No CDN detected'}
>
{request.cdnAnalysis ? getCDNIcon(request.cdnAnalysis) : '-'}
</td>
<td className={`${styles.cacheCell} ${request.fromCache ? styles.cacheFromCache : request.connectionReused ? styles.cacheConnectionReused : styles.cacheNetwork}`}>
{request.fromCache ? '💾' : request.connectionReused ? '🔄' : '🌐'}
</td>
</tr>
{/* Expanded Row Details */}
{isExpanded && <RequestRowDetails request={request} />}
</>
)
}
export default RequestRowSummary

View File

@ -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<string>
onToggleRowExpansion: (requestId: string) => void
}
const RequestsTable: React.FC<RequestsTableProps> = ({
httpRequests,
showScreenshots,
paginatedTimelineEntries,
paginatedRequests,
showQueueAnalysis,
expandedRows,
onToggleRowExpansion
}) => {
return (
<>
{/* Requests Table */}
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
<th className={`${styles.tableHeaderCell} ${styles.center} ${styles.expandColumn}`}>Expand</th>
<th className={`${styles.tableHeaderCell} ${styles.left}`}>Method</th>
<th className={`${styles.tableHeaderCell} ${styles.center}`}>Status</th>
<th className={`${styles.tableHeaderCell} ${styles.left}`}>Type</th>
<th className={`${styles.tableHeaderCell} ${styles.center}`}>Priority</th>
<th className={`${styles.tableHeaderCell} ${styles.right}`}>Start Time</th>
<th className={`${styles.tableHeaderCell} ${styles.right}`}>Queue Time</th>
<th className={`${styles.tableHeaderCell} ${styles.right}`}>DNS</th>
<th className={`${styles.tableHeaderCell} ${styles.right}`}>Connection</th>
<th className={`${styles.tableHeaderCell} ${styles.right}`}>Server Latency</th>
<th className={`${styles.tableHeaderCell} ${styles.left}`}>URL</th>
<th className={`${styles.tableHeaderCell} ${styles.right}`}>Duration</th>
<th className={`${styles.tableHeaderCell} ${styles.right}`}>Total Response Time</th>
<th className={`${styles.tableHeaderCell} ${styles.right}`}>Size</th>
<th className={`${styles.tableHeaderCell} ${styles.right}`}>Content-Length</th>
<th className={`${styles.tableHeaderCell} ${styles.center}`}>Protocol</th>
<th className={`${styles.tableHeaderCell} ${styles.center}`}>CDN</th>
<th className={`${styles.tableHeaderCell} ${styles.center}`}>Cache</th>
</tr>
</thead>
<tbody>
{(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 (
<ScreenshotRow
key={`screenshot-${screenshot.index}`}
screenshot={screenshot}
firstRequestTime={firstRequestTime}
/>
)
}
const request = entry.data as HTTPRequest
const isExpanded = expandedRows.has(request.requestId)
return (
<RequestRowSummary
key={request.requestId}
request={request}
showQueueAnalysis={showQueueAnalysis}
isExpanded={isExpanded}
onToggleRowExpansion={onToggleRowExpansion}
/>
)
})}
</tbody>
</table>
</div>
{/* No Results Message */}
{(showScreenshots ? paginatedTimelineEntries.length === 0 : paginatedRequests.length === 0) && (
<div className={styles.noResults}>
{showScreenshots ?
'No timeline entries found matching the current filters' :
'No HTTP requests found matching the current filters'}
</div>
)}
</>
)
}
export default RequestsTable

View File

@ -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<ScreenshotRowProps> = ({
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(`
<html>
<head><title>Screenshot at ${formatDuration(timeOffset)}</title></head>
<body style="margin:0;padding:20px;background:#000;display:flex;justify-content:center;align-items:center;min-height:100vh;">
<img src="${screenshot.screenshot}" style="max-width:100%;max-height:100vh;border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.5);" alt="Full size screenshot" />
</body>
</html>
`)
}
}
return (
<tr key={`screenshot-${screenshot.index}`} className={styles.screenshotRow}>
<td colSpan={18} className={styles.screenshotContainer}>
<div className={styles.screenshotLabel}>
📸 Screenshot
</div>
<div className={styles.screenshotTime}>
<strong>Time:</strong> {formatDuration(timeOffset)}
</div>
<img
src={screenshot.screenshot}
alt={`Screenshot at ${formatDuration(timeOffset)}`}
className={styles.screenshotImage}
onClick={handleScreenshotClick}
/>
<div className={styles.screenshotHint}>
Click to view full size
</div>
</td>
</tr>
)
}
export default ScreenshotRow

View File

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

View File

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

View File

@ -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'
}
}

View File

@ -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 = <T>(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')
}

View File

@ -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`
}

View File

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

View File

@ -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<string, HTTPRequest>()
// 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
}
}
}

View File

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

View File

@ -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<ImageData> => {
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<ScreenshotEvent[]> => {
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
}

View File

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

View File

@ -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) + '...'
}

View File

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

20
src/types/trace.ts Normal file
View File

@ -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[]
}