Refactored code to use css and be more modular.
This commit is contained in:
parent
8a791a1186
commit
ec91cfbafd
10
CLAUDE.md
10
CLAUDE.md
@ -55,6 +55,13 @@ This is a React + TypeScript + Vite project called "perfviz" - a modern web appl
|
||||
- **HMR (Hot Module Replacement)** is enabled through Vite's React plugin
|
||||
- **Strict mode** is enabled in development for better debugging
|
||||
- **Browser targets** are ES2022+ for modern JavaScript features
|
||||
- **Lines of code per function**: Functions should not exceed 50 lines. If they do, add a comment `//@LONGFUNCTION` to indicate this.
|
||||
- **Lines of code per function**: Functions should have a hard limit of 200 lines, if we exceed this, prompt with approaches to refactor.
|
||||
- **File size limits**: TypeScript files should not exceed 100 lines. If a file is over 100 lines but functions are under 150 lines, add `//@LONGFILE` comment at the top and warn the user. Files over 250 lines must be prompted to refactor.
|
||||
- **Imports**: Ensure all imports are used, remove unused imports, and ensure imports are alphabetized and at the top of the file.
|
||||
- **Code Style**: Follow consistent coding style with mostly widely used indentation, spacing, and comments for clarity.
|
||||
- **Error Handling**: Use try/catch blocks for asynchronous operations and provide meaningful error messages.
|
||||
- **CSS versus Styled Components**: Use CSS modules for styling instead of styled-components for better performance and simplicity.
|
||||
|
||||
## Babylon.js Integration
|
||||
|
||||
@ -65,4 +72,5 @@ This is a React + TypeScript + Vite project called "perfviz" - a modern web appl
|
||||
- **Engine Integration**: Engine handles canvas interaction and render loop
|
||||
- **React Integration**: Proper cleanup with engine disposal in useEffect return function
|
||||
- **Version-Specific Documentation**: ALWAYS check Babylon.js documentation for version 8.21.1 specifically to avoid deprecated methods and ensure current API usage
|
||||
- **API Verification**: Before suggesting any Babylon.js code, verify method signatures and availability in the current version
|
||||
- **API Verification**: Before suggesting any Babylon.js code, verify method signatures and availability in the current version
|
||||
-
|
578
analysis.css
Normal file
578
analysis.css
Normal 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
707
optimized.css
Normal 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;
|
||||
}
|
27
src/App.tsx
27
src/App.tsx
@ -2,12 +2,14 @@ import { useState, useEffect } from 'react'
|
||||
import './App.css'
|
||||
import TraceViewer from './components/TraceViewer'
|
||||
import PhaseViewer from './components/PhaseViewer'
|
||||
import HTTPRequestViewer from './components/HTTPRequestViewer'
|
||||
import HTTPRequestViewer from './components/httprequestviewer/HTTPRequestViewer'
|
||||
import RequestDebugger from './components/RequestDebugger'
|
||||
import TraceUpload from './components/TraceUpload'
|
||||
import TraceSelector from './components/TraceSelector'
|
||||
import { traceDatabase } from './utils/traceDatabase'
|
||||
import { useDatabaseTraceData } from './hooks/useDatabaseTraceData'
|
||||
|
||||
type AppView = 'trace' | 'phases' | 'http'
|
||||
type AppView = 'trace' | 'phases' | 'http' | 'debug'
|
||||
type AppMode = 'selector' | 'upload' | 'analysis'
|
||||
|
||||
function App() {
|
||||
@ -16,6 +18,9 @@ function App() {
|
||||
const [selectedTraceId, setSelectedTraceId] = useState<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()
|
||||
@ -164,6 +169,20 @@ 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>
|
||||
|
||||
@ -178,6 +197,10 @@ function App() {
|
||||
{currentView === 'http' && (
|
||||
<HTTPRequestViewer traceId={selectedTraceId} />
|
||||
)}
|
||||
|
||||
{currentView === 'debug' && traceData && (
|
||||
<RequestDebugger traceEvents={traceData.traceEvents} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
@ -38,7 +38,7 @@ function createTimelineLabel(
|
||||
// Create label texture
|
||||
const labelTexture = new DynamicTexture(`timeLabel_${labelId}`, { width: 80, height: 32 }, scene)
|
||||
labelTexture.hasAlpha = true
|
||||
labelTexture.drawText(timeLabelText, null, null, '12px Arial', 'white', 'rgba(0,0,0,0.5)', true)
|
||||
labelTexture.drawText(timeLabelText, null, null, '12px Arial', 'white', 'rgba(0,0,0,0.9)', true)
|
||||
|
||||
// Create label plane
|
||||
const timeLabelPlane = MeshBuilder.CreatePlane(`timeLabelPlane_${labelId}`, { size: 0.5 }, scene)
|
||||
@ -76,13 +76,20 @@ interface HTTPRequest {
|
||||
connectionReused: boolean
|
||||
}
|
||||
|
||||
interface ScreenshotEvent {
|
||||
timestamp: number
|
||||
screenshot: string // base64 image data
|
||||
index: number
|
||||
}
|
||||
|
||||
interface BabylonTimelineViewerProps {
|
||||
width?: number
|
||||
height?: number
|
||||
httpRequests?: HTTPRequest[]
|
||||
screenshots?: ScreenshotEvent[]
|
||||
}
|
||||
|
||||
export default function BabylonTimelineViewer({ width = 800, height = 600, httpRequests = [] }: BabylonTimelineViewerProps) {
|
||||
export default function BabylonTimelineViewer({ width = 800, height = 600, httpRequests = [], screenshots = [] }: BabylonTimelineViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const engineRef = useRef<Engine | null>(null)
|
||||
const sceneRef = useRef<Scene | null>(null)
|
||||
@ -116,18 +123,20 @@ export default function BabylonTimelineViewer({ width = 800, height = 600, httpR
|
||||
|
||||
scene.activeCamera = camera
|
||||
|
||||
const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene)
|
||||
light.intensity = 0.7
|
||||
const light = new HemisphericLight('light', new Vector3(-5, 3, -1), scene)
|
||||
const light2 = new HemisphericLight('light', new Vector3(5, 3, -1), scene)
|
||||
light.intensity = 0.9
|
||||
light2.intensity = 0.9
|
||||
|
||||
// Create ground plane oriented along the Z-axis timeline
|
||||
const ground = MeshBuilder.CreateGround('ground', { width: 20, height: 12 }, scene)
|
||||
/*const ground = MeshBuilder.CreateGround('ground', { width: 20, height: 12 }, scene)
|
||||
ground.rotation.x = -Math.PI / 2 // Rotate to lie along Z-axis
|
||||
ground.position = new Vector3(0, 0, 5) // Center at middle of timeline
|
||||
const groundMaterial = new StandardMaterial('groundMaterial', scene)
|
||||
groundMaterial.diffuseColor = new Color3(0.2, 0.2, 0.2)
|
||||
groundMaterial.alpha = 0.3
|
||||
ground.material = groundMaterial
|
||||
|
||||
*/
|
||||
// Create timeline start marker at Z = 0
|
||||
const startMarker = MeshBuilder.CreateSphere('timelineStart', { diameter: 0.3 }, scene)
|
||||
startMarker.position = new Vector3(0, 0, 0)
|
||||
@ -136,13 +145,33 @@ export default function BabylonTimelineViewer({ width = 800, height = 600, httpR
|
||||
startMaterial.specularColor = new Color3(0.5, 1, 0.5)
|
||||
startMarker.material = startMaterial
|
||||
|
||||
// Create timeline end marker at Z = 10
|
||||
const endMarker = MeshBuilder.CreateSphere('timelineEnd', { diameter: 0.3 }, scene)
|
||||
endMarker.position = new Vector3(0, 0, 10)
|
||||
const endMaterial = new StandardMaterial('endMaterial', scene)
|
||||
endMaterial.diffuseColor = new Color3(0.8, 0.2, 0.2) // Red for end
|
||||
endMaterial.specularColor = new Color3(1, 0.5, 0.5)
|
||||
endMarker.material = endMaterial
|
||||
// Create timeline end marker at actual completion position if we have requests
|
||||
if (httpRequests && httpRequests.length > 0) {
|
||||
// Find the actual completion time of the last finishing request
|
||||
const endTimes = httpRequests.map(req => (req.timing.startOffset || 0) + (req.timing.duration || 0))
|
||||
const minStartTime = Math.min(...httpRequests.map(req => req.timing.startOffset || 0))
|
||||
const actualMaxEndTime = Math.max(...endTimes)
|
||||
const totalTimeRange = actualMaxEndTime - minStartTime
|
||||
|
||||
// Calculate the Z position for the actual end time
|
||||
const normalizedEndTime = totalTimeRange > 0 ? (actualMaxEndTime - minStartTime) / totalTimeRange : 1
|
||||
const actualEndZ = 0 + (normalizedEndTime * (20 - 0)) // Using minZ=0, maxZ=20
|
||||
|
||||
const endMarker = MeshBuilder.CreateSphere('timelineEnd', { diameter: 0.3 }, scene)
|
||||
endMarker.position = new Vector3(0, 0, actualEndZ)
|
||||
const endMaterial = new StandardMaterial('endMaterial', scene)
|
||||
endMaterial.diffuseColor = new Color3(0.8, 0.2, 0.2) // Red for end
|
||||
endMaterial.specularColor = new Color3(1, 0.5, 0.5)
|
||||
endMarker.material = endMaterial
|
||||
} else {
|
||||
// Fallback to Z = 20 if no requests
|
||||
const endMarker = MeshBuilder.CreateSphere('timelineEnd', { diameter: 0.3 }, scene)
|
||||
endMarker.position = new Vector3(0, 0, 20)
|
||||
const endMaterial = new StandardMaterial('endMaterial', scene)
|
||||
endMaterial.diffuseColor = new Color3(0.8, 0.2, 0.2) // Red for end
|
||||
endMaterial.specularColor = new Color3(1, 0.5, 0.5)
|
||||
endMarker.material = endMaterial
|
||||
}
|
||||
|
||||
|
||||
// Visualize HTTP requests in timeline swimlanes by hostname
|
||||
@ -224,6 +253,33 @@ export default function BabylonTimelineViewer({ width = 800, height = 600, httpR
|
||||
createTimelineLabel(scene, i.toString(), new Vector3(-0.5, -0.5, zPosition), actualTimeOffset)
|
||||
}
|
||||
|
||||
// Create filmstrip planes for screenshot events
|
||||
if (screenshots.length > 0) {
|
||||
screenshots.forEach((screenshot, index) => {
|
||||
// Calculate Z position for this screenshot timestamp using the same normalization as HTTP requests
|
||||
const normalizedScreenshotTime = totalTimeRange > 0 ? (screenshot.timestamp - minStartTime) / totalTimeRange : 0
|
||||
const screenshotZ = minZ + (normalizedScreenshotTime * (maxZ - minZ))
|
||||
|
||||
|
||||
const screenshotPlane = MeshBuilder.CreatePlane(`screenshotPlane_${index}`, {
|
||||
width: 2,
|
||||
height: 2
|
||||
}, scene)
|
||||
|
||||
// Position the plane at x:0 y:1
|
||||
screenshotPlane.position = new Vector3(0, 1, screenshotZ)
|
||||
screenshotPlane.rotation.y = Math.PI / 2 // Rotate to face along timeline
|
||||
|
||||
// Create semi-transparent material
|
||||
const screenshotMaterial = new StandardMaterial(`screenshotMaterial_${index}`, scene)
|
||||
screenshotMaterial.diffuseColor = new Color3(1, 1, 1) // White plane
|
||||
screenshotMaterial.alpha = 0.5 // 50% opacity
|
||||
screenshotMaterial.backFaceCulling = false // Visible from both sides
|
||||
|
||||
screenshotPlane.material = screenshotMaterial
|
||||
})
|
||||
}
|
||||
|
||||
hostnamesWithStartTimes.forEach(({ hostname }, sortedIndex) => {
|
||||
// Calculate X position alternating left/right from origin, with distance increasing by start time order
|
||||
// Index 0 (earliest) -> X = 0, Index 1 -> X = -2, Index 2 -> X = 2, Index 3 -> X = -4, Index 4 -> X = 4, etc.
|
||||
@ -445,7 +501,7 @@ export default function BabylonTimelineViewer({ width = 800, height = 600, httpR
|
||||
window.removeEventListener('resize', handleResize)
|
||||
engine.dispose()
|
||||
}
|
||||
}, [httpRequests])
|
||||
}, [httpRequests, screenshots])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
File diff suppressed because it is too large
Load Diff
811
src/components/RequestDebugger.tsx
Normal file
811
src/components/RequestDebugger.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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); }
|
||||
}
|
||||
}
|
13
src/components/httprequestviewer/HTTPRequestLoading.tsx
Normal file
13
src/components/httprequestviewer/HTTPRequestLoading.tsx
Normal 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>
|
||||
</>);
|
||||
}
|
705
src/components/httprequestviewer/HTTPRequestViewer.module.css
Normal file
705
src/components/httprequestviewer/HTTPRequestViewer.module.css
Normal 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;
|
||||
}
|
388
src/components/httprequestviewer/HTTPRequestViewer.tsx
Normal file
388
src/components/httprequestviewer/HTTPRequestViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
139
src/components/httprequestviewer/RequestFilters.module.css
Normal file
139
src/components/httprequestviewer/RequestFilters.module.css
Normal 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;
|
||||
}
|
290
src/components/httprequestviewer/RequestFilters.tsx
Normal file
290
src/components/httprequestviewer/RequestFilters.tsx
Normal 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
|
224
src/components/httprequestviewer/RequestRowDetails.tsx
Normal file
224
src/components/httprequestviewer/RequestRowDetails.tsx
Normal 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
|
139
src/components/httprequestviewer/RequestRowSummary.tsx
Normal file
139
src/components/httprequestviewer/RequestRowSummary.tsx
Normal 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
|
106
src/components/httprequestviewer/RequestsTable.tsx
Normal file
106
src/components/httprequestviewer/RequestsTable.tsx
Normal 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
|
55
src/components/httprequestviewer/ScreenshotRow.tsx
Normal file
55
src/components/httprequestviewer/ScreenshotRow.tsx
Normal 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
|
34
src/components/httprequestviewer/lib/addTimingToRequest.ts
Normal file
34
src/components/httprequestviewer/lib/addTimingToRequest.ts
Normal 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
|
||||
}
|
||||
}
|
250
src/components/httprequestviewer/lib/analysisUtils.ts
Normal file
250
src/components/httprequestviewer/lib/analysisUtils.ts
Normal 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
|
||||
}
|
||||
}
|
130
src/components/httprequestviewer/lib/colorUtils.ts
Normal file
130
src/components/httprequestviewer/lib/colorUtils.ts
Normal 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'
|
||||
}
|
||||
}
|
87
src/components/httprequestviewer/lib/filterUtils.ts
Normal file
87
src/components/httprequestviewer/lib/filterUtils.ts
Normal 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')
|
||||
}
|
13
src/components/httprequestviewer/lib/formatUtils.ts
Normal file
13
src/components/httprequestviewer/lib/formatUtils.ts
Normal 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`
|
||||
}
|
96
src/components/httprequestviewer/lib/httpRequestConstants.ts
Normal file
96
src/components/httprequestviewer/lib/httpRequestConstants.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
295
src/components/httprequestviewer/lib/httpRequestProcessor.ts
Normal file
295
src/components/httprequestviewer/lib/httpRequestProcessor.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
30
src/components/httprequestviewer/lib/requestPostProcessor.ts
Normal file
30
src/components/httprequestviewer/lib/requestPostProcessor.ts
Normal 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
|
||||
}
|
125
src/components/httprequestviewer/lib/screenshotUtils.ts
Normal file
125
src/components/httprequestviewer/lib/screenshotUtils.ts
Normal 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
|
||||
}
|
87
src/components/httprequestviewer/lib/sortRequests.ts
Normal file
87
src/components/httprequestviewer/lib/sortRequests.ts
Normal 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);
|
||||
}
|
16
src/components/httprequestviewer/lib/urlUtils.ts
Normal file
16
src/components/httprequestviewer/lib/urlUtils.ts
Normal 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) + '...'
|
||||
}
|
70
src/components/httprequestviewer/types/httpRequest.ts
Normal file
70
src/components/httprequestviewer/types/httpRequest.ts
Normal 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
20
src/types/trace.ts
Normal 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[]
|
||||
}
|
Loading…
Reference in New Issue
Block a user