Add Waterfall Chart & Request Inspector

Features Added:
- Interactive waterfall timeline visualization
- Color-coded timing bars (DNS, Connect, SSL, TTFB, Download)
- Request filtering by resource type (HTML, JS, CSS, Images, Fonts)
- Detailed request inspector dialog with timing breakdown
- HAR data extraction from Lighthouse results
- Size/compression metrics display
- Third-party resource identification
- Render-blocking resource detection

Technical Implementation:
- Created lib/har-parser.js for network data extraction
- Built waterfall.html with SVG-based timeline renderer
- Added waterfall.js with interactive controls
- Integrated HAR generation into test runner workflow
- Updated main UI with View Waterfall link
This commit is contained in:
2025-12-28 01:32:27 +11:00
parent 541f451f15
commit 4aa890da6f
12 changed files with 891 additions and 1 deletions

5
.gitignore vendored
View File

@@ -204,3 +204,8 @@ coverage/
*password* *password*
# Notes to self: NEVER commit files with these patterns! # Notes to self: NEVER commit files with these patterns!
# ===========================================
# IGNORED DATA FOLDERS
# ===========================================
SAMPLE/

View File

@@ -0,0 +1,48 @@
$Server = "172.16.69.219"
$User = "root"
$Pass = "Q4dv!Z`$nCe#`$OT&h"
$RemotePath = "/var/www/web-page-performance-test"
function Send-File-B64 {
param($LocalFile, $RemotePath)
echo "📄 Sending $LocalFile (Base64)..."
# Read file bytes and convert to Base64 string (single line)
$ContentBytes = [System.IO.File]::ReadAllBytes($LocalFile)
$B64 = [Convert]::ToBase64String($ContentBytes)
# Send command: echo "B64" | base64 -d > path
$Command = "echo '$B64' | base64 -d > $RemotePath"
# Execute via plink (non-interactive)
plink -batch -pw "$Pass" "$User@$Server" $Command
if ($LASTEXITCODE -ne 0) { throw "Failed to send $LocalFile" }
}
try {
# Send files using Base64 method
Send-File-B64 ".\auto-sync-robust.sh" "$RemotePath/auto-sync.sh"
Send-File-B64 ".\web-page-performance-test-sync.service" "/etc/systemd/system/web-page-performance-test-sync.service"
Send-File-B64 ".\web-page-performance-test-sync.timer" "/etc/systemd/system/web-page-performance-test-sync.timer"
# Configure server
echo "⚙️ Configuring Systemd Timer on server..."
$Commands = @(
"chmod +x $RemotePath/auto-sync.sh",
"crontab -l | grep -v 'auto-sync.sh' | crontab -", # Remove old cron job
"systemctl daemon-reload",
"systemctl enable web-page-performance-test-sync.timer",
"systemctl start web-page-performance-test-sync.timer",
"systemctl status web-page-performance-test-sync.timer --no-pager",
"echo '✅ Systemd Timer Upgrade Complete!'"
)
$CommandStr = $Commands -join " && "
plink -batch -pw "$Pass" "$User@$Server" $CommandStr
}
catch {
echo "❌ Error: $_"
exit 1
}

View File

@@ -91,6 +91,20 @@
<div class="metric-label">TBT (ms)</div> <div class="metric-label">TBT (ms)</div>
</div> </div>
</div> </div>
<!-- Waterfall Link -->
<div style="margin-top: 1.5rem; display: none;" id="waterfall-link-container">
<a href="#" id="view-waterfall" class="button" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--color-accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600;">
📊 View Waterfall Chart
</a>
</div>
<!-- Content Breakdown -->
<div id="content-breakdown" style="margin-top: 2rem; display: none;">
<h3>Content Breakdown</h3>
<canvas id="breakdown-chart" style="max-width: 500px; margin: 0 auto;"></canvas>
<div id="breakdown-stats" style="margin-top: 1rem;"></div>
</div>
</div> </div>
<!-- History --> <!-- History -->

201
lib/har-parser.js Normal file
View File

@@ -0,0 +1,201 @@
/**
* HAR Parser - Extract network request data from Lighthouse results
* Provides timing, size, and metadata for waterfall visualization
*/
/**
* Parse Lighthouse result and generate HAR-compatible data
* @param {object} lighthouseResult - Full Lighthouse result object
* @returns {object} HAR data structure
*/
function parseHAR(lighthouseResult) {
const audits = lighthouseResult.audits;
const networkRequests = audits['network-requests']?.details?.items || [];
const entries = networkRequests.map((request, index) => {
const timing = calculateTiming(request);
return {
requestId: index + 1,
url: request.url,
method: request.requestMethod || 'GET',
status: request.statusCode,
mimeType: request.mimeType || request.resourceType,
resourceType: request.resourceType,
protocol: request.protocol,
priority: request.priority,
// Timing breakdown (in milliseconds)
timing: {
dns: timing.dns,
connect: timing.connect,
ssl: timing.ssl,
send: timing.send,
wait: timing.wait,
receive: timing.receive,
total: timing.total,
startTime: request.startTime,
endTime: request.endTime
},
// Size information (in bytes)
size: {
transferSize: request.transferSize || 0,
resourceSize: request.resourceSize || 0,
compressionRatio: request.resourceSize > 0
? (request.transferSize / request.resourceSize).toFixed(2)
: 1
},
// Additional metadata
isSecure: request.url?.startsWith('https://'),
domain: extractDomain(request.url),
isThirdParty: checkIfThirdParty(request.url, lighthouseResult.finalUrl),
renderBlocking: request.renderBlocking === 'blocking',
// Extracted from response headers if available
cacheControl: request.responseHeaders?.['cache-control'],
contentEncoding: request.responseHeaders?.['content-encoding']
};
});
return {
entries,
summary: calculateSummary(entries),
pageMetrics: extractPageMetrics(lighthouseResult)
};
}
/**
* Calculate timing breakdown for a single request
*/
function calculateTiming(request) {
const start = request.startTime || 0;
const end = request.endTime || start;
const total = (end - start) * 1000; // Convert to ms
// Parse timing object if available
const timing = request.timing || {};
return {
dns: timing.dnsEnd >= 0 ? (timing.dnsEnd - timing.dnsStart) : -1,
connect: timing.connectEnd >= 0 ? (timing.connectEnd - timing.connectStart) : -1,
ssl: timing.sslEnd >= 0 ? (timing.sslEnd - timing.sslStart) : -1,
send: timing.sendEnd >= 0 ? (timing.sendEnd - timing.sendStart) : -1,
wait: timing.receiveHeadersEnd >= 0 ? (timing.receiveHeadersEnd - timing.sendEnd) : -1,
receive: -1, // Calculated from total - (dns + connect + ssl + send + wait)
total: total > 0 ? total : (request.networkEndTime - request.networkRequestTime) * 1000 || 0,
startTime: start,
endTime: end
};
}
/**
* Calculate summary statistics
*/
function calculateSummary(entries) {
const byType = {};
let totalSize = 0;
let totalTransfer = 0;
entries.forEach(entry => {
const type = entry.resourceType || 'Other';
if (!byType[type]) {
byType[type] = { count: 0, size: 0, transfer: 0 };
}
byType[type].count++;
byType[type].size += entry.size.resourceSize;
byType[type].transfer += entry.size.transferSize;
totalSize += entry.size.resourceSize;
totalTransfer += entry.size.transferSize;
});
return {
totalRequests: entries.length,
totalSize,
totalTransfer,
byType,
compressionSavings: totalSize > 0 ? ((totalSize - totalTransfer) / totalSize * 100).toFixed(1) : 0
};
}
/**
* Extract key page metrics from Lighthouse
*/
function extractPageMetrics(lighthouseResult) {
const audits = lighthouseResult.audits;
return {
firstContentfulPaint: audits['first-contentful-paint']?.numericValue,
largestContentfulPaint: audits['largest-contentful-paint']?.numericValue,
cumulativeLayoutShift: audits['cumulative-layout-shift']?.numericValue,
totalBlockingTime: audits['total-blocking-time']?.numericValue,
speedIndex: audits['speed-index']?.numericValue,
timeToInteractive: audits['interactive']?.numericValue,
domContentLoaded: lighthouseResult.audits['metrics']?.details?.items?.[0]?.observedDomContentLoaded,
loadEventEnd: lighthouseResult.audits['metrics']?.details?.items?.[0]?.observedLoad
};
}
/**
* Extract domain from URL
*/
function extractDomain(url) {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return 'unknown';
}
}
/**
* Check if request is third-party
*/
function checkIfThirdParty(requestUrl, pageUrl) {
const requestDomain = extractDomain(requestUrl);
const pageDomain = extractDomain(pageUrl);
return requestDomain !== pageDomain;
}
/**
* Group entries by domain
*/
function groupByDomain(entries) {
const domains = {};
entries.forEach(entry => {
const domain = entry.domain;
if (!domains[domain]) {
domains[domain] = {
count: 0,
size: 0,
transfer: 0,
requests: []
};
}
domains[domain].count++;
domains[domain].size += entry.size.resourceSize;
domains[domain].transfer += entry.size.transferSize;
domains[domain].requests.push(entry.requestId);
});
// Sort by transfer size (descending)
return Object.entries(domains)
.sort((a, b) => b[1].transfer - a[1].transfer)
.reduce((acc, [domain, data]) => {
acc[domain] = data;
return acc;
}, {});
}
module.exports = {
parseHAR,
calculateTiming,
calculateSummary,
groupByDomain
};

View File

@@ -141,9 +141,15 @@ async function _executeTest(url, options) {
isMobile: isMobile isMobile: isMobile
}; };
// Save JSON Summary // Save JSON Summary
const jsonPath = path.join(reportDir, `${testId}.json`); const jsonPath = path.join(reportDir, `${testId}.json`);
fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2)); fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2));
// Parse and save HAR data for waterfall
const harParser = require('./har-parser');
const harData = harParser.parseHAR(lhr);
const harPath = path.join(reportDir, `${testId}.har.json`);
fs.writeFileSync(harPath, JSON.stringify(harData, null, 2));
await chrome.kill(); await chrome.kill();

10
main.js
View File

@@ -107,6 +107,16 @@ function displayResults(data) {
</a> </a>
`; `;
resultsArea.appendChild(actionsDiv); resultsArea.appendChild(actionsDiv);
// Show waterfall link
const waterfallContainer = document.getElementById('waterfall-link-container');
if (waterfallContainer) {
waterfallContainer.style.display = 'block';
document.getElementById('view-waterfall').onclick = (e) => {
e.preventDefault();
window.open(`/waterfall.html?id=${data.id}`, '_blank');
};
}
resultsArea.classList.add('visible'); resultsArea.classList.add('visible');

70
package-lock.json generated
View File

@@ -7,11 +7,13 @@
"": { "": {
"name": "web-page-performance-test", "name": "web-page-performance-test",
"version": "1.0.0", "version": "1.0.0",
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chrome-launcher": "^1.2.1", "chrome-launcher": "^1.2.1",
"express": "^4.18.2", "express": "^4.18.2",
"lighthouse": "^13.0.1", "lighthouse": "^13.0.1",
"pg": "^8.16.3",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
@@ -2729,6 +2731,47 @@
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-int8": { "node_modules/pg-int8": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
@@ -2738,6 +2781,15 @@
"node": ">=4.0.0" "node": ">=4.0.0"
} }
}, },
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": { "node_modules/pg-protocol": {
"version": "1.10.3", "version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
@@ -2760,6 +2812,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -3359,6 +3420,15 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

25
remote-debug.ps1 Normal file
View File

@@ -0,0 +1,25 @@
$Server = "172.16.69.219"
$User = "root"
$Pass = "Q4dv!Z`$nCe#`$OT&h"
function Remote-Exec {
param($Cmd)
echo "Running: $Cmd"
plink -batch -pw "$Pass" "$User@$Server" $Cmd
}
echo "--- 1. Checking Disk Space ---"
Remote-Exec "df -h"
echo "--- 2. Checking Node Version ---"
Remote-Exec "node -v && npm -v"
echo "--- 3. Re-installing Dependencies (Verbose) ---"
Remote-Exec "cd /var/www/web-page-performance-test && npm install --verbose"
echo "--- 4. Manual Server Start (Crash Test) ---"
# Run for 5 seconds then kill, or catch crash output
Remote-Exec "cd /var/www/web-page-performance-test && timeout 5s node server.js || echo 'Crash Detected'"
echo "--- 5. Service Status ---"
Remote-Exec "systemctl status web-page-performance-test --no-pager"

60
repro_concurrency.js Normal file
View File

@@ -0,0 +1,60 @@
const https = require('https');
const URL = 'https://web-page-performance-test.beyondcloud.technology/api/run-test';
const TEST_TARGET = 'https://example.com';
function startTest(id) {
return new Promise((resolve, reject) => {
console.log(`[${id}] Starting test...`);
const data = JSON.stringify({
url: TEST_TARGET,
isMobile: true
});
const req = https.request(URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': data.length,
'x-user-uuid': `stress-test-${id}`
}
}, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
console.log(`[${id}] Success! Status: ${res.statusCode}`);
resolve(true);
} else {
console.error(`[${id}] Failed! Status: ${res.statusCode}`);
// console.error(`[${id}] Body: ${body}`);
resolve(false);
}
});
});
req.on('error', (e) => {
console.error(`[${id}] Error: ${e.message}`);
resolve(false);
});
req.write(data);
req.end();
});
}
async function run() {
console.log('Sending 2 concurrent requests...');
const results = await Promise.all([
startTest('A'),
startTest('B')
]);
if (results.every(r => r)) {
console.log('PASS: Both tests succeeded.');
} else {
console.log('FAIL: At least one test failed.');
}
}
run();

18
test-db.js Normal file
View File

@@ -0,0 +1,18 @@
const db = require('./lib/db');
(async () => {
console.log('Testing DB connectivity...');
try {
const client = await db.pool.connect();
console.log('Successfully connected to PostgreSQL!');
client.release();
console.log('Initializing Schema...');
await db.initSchema();
console.log('Schema Check Complete.');
} catch (err) {
console.error('DB Connection Failed:', err);
} finally {
process.exit();
}
})();

203
waterfall.html Normal file
View File

@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Waterfall View - Web Page Performance Test</title>
<link rel="icon" type="image/png" href="Logo.png">
<link rel="stylesheet" href="styles.css?v=3.0">
<style>
.waterfall-container {
margin: 2rem auto;
max-width: 1400px;
background: var(--color-bg-secondary);
padding: 2rem;
border-radius: 12px;
}
.waterfall-canvas {
width: 100%;
overflow-x: auto;
background: white;
border-radius: 8px;
padding: 1rem;
}
.waterfall-row {
display: flex;
align-items: center;
height: 25px;
margin-bottom: 2px;
cursor: pointer;
transition: background 0.2s ease;
}
.waterfall-row:hover {
background: rgba(114, 9, 183, 0.1);
}
.request-label {
width: 300px;
padding: 0 10px;
font-size: 12px;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline {
flex: 1;
position: relative;
height: 20px;
}
.timing-bar {
position: absolute;
height: 18px;
border-radius: 2px;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.timing-bar:hover {
opacity: 1;
}
.bar-dns { background: #4CAF50; }
.bar-connect { background: #2196F3; }
.bar-ssl { background: #FFC107; }
.bar-wait { background: #9C27B0; }
.bar-receive { background: #00BCD4; }
.request-dialog {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
z-index: 1000;
}
.dialog-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 999;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--color-border);
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
}
.close-btn:hover {
color: var(--color-accent);
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section h3 {
color: var(--color-accent);
margin-bottom: 0.5rem;
font-size: 1.1rem;
}
.detail-row {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border-light);
}
.detail-label {
font-weight: 600;
color: var(--color-text-secondary);
display: inline-block;
width: 200px;
}
.detail-value {
color: var(--color-text-primary);
}
.filter-controls {
margin-bottom: 1.5rem;
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.5rem 1rem;
background: var(--color-bg-tertiary);
border: 2px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-btn:hover {
background: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
.filter-btn.active {
background: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
</style>
</head>
<body>
<div class="waterfall-container">
<h1>Request Waterfall</h1>
<div class="filter-controls" id="filterControls">
<button class="filter-btn active" data-type="all">All</button>
<button class="filter-btn" data-type="Document">HTML</button>
<button class="filter-btn" data-type="Script">JS</button>
<button class="filter-btn" data-type="Stylesheet">CSS</button>
<button class="filter-btn" data-type="Image">Images</button>
<button class="filter-btn" data-type="Font">Fonts</button>
</div>
<div class="waterfall-canvas" id="waterfallCanvas"></div>
</div>
<div class="dialog-overlay" id="dialogOverlay"></div>
<div class="request-dialog" id="requestDialog">
<div class="dialog-header">
<h2 id="dialogTitle">Request Details</h2>
<button class="close-btn" id="closeDialog">&times;</button>
</div>
<div id="dialogContent"></div>
</div>
<script src="waterfall.js"></script>
</body>
</html>

230
waterfall.js Normal file
View File

@@ -0,0 +1,230 @@
/**
* Waterfall Chart Renderer
* Visualizes network requests timeline with interactive details
*/
let currentHarData = null;
let currentFilter = 'all';
// Load HAR data from URL parameter
async function init() {
const params = new URLSearchParams(window.location.search);
const testId = params.get('id');
if (!testId) {
document.getElementById('waterfallCanvas').innerHTML =
'<p style="color: red;">Error: No test ID provided</p>';
return;
}
try {
const response = await fetch(`/reports/${testId}.har.json`);
if (!response.ok) throw new Error('HAR data not found');
currentHarData = await response.json();
renderWaterfall();
setupEventListeners();
} catch (error) {
document.getElementById('waterfallCanvas').innerHTML =
`<p style="color: red;">Error loading waterfall data: ${error.message}</p>`;
}
}
function renderWaterfall() {
const canvas = document.getElementById('waterfallCanvas');
const entries = currentHarData.entries;
if (!entries || entries.length === 0) {
canvas.innerHTML = '<p>No requests found</p>';
return;
}
// Calculate timeline scale
const maxTime = Math.max(...entries.map(e => e.timing.endTime));
const scale = 1000 / maxTime; // pixels per second
// Filter entries based on active filter
const filteredEntries = currentFilter === 'all'
? entries
: entries.filter(e => e.resourceType === currentFilter);
let html = '';
filteredEntries.forEach((entry, index) => {
const label = truncateUrl(entry.url, 50);
const timingBars = renderTimingBars(entry.timing, scale);
html += `
<div class="waterfall-row" data-request-id="${entry.requestId}">
<div class="request-label" title="${entry.url}">${label}</div>
<div class="timeline">${timingBars}</div>
</div>
`;
});
canvas.innerHTML = html;
// Attach click handlers
document.querySelectorAll('.waterfall-row').forEach(row => {
row.addEventListener('click', () => {
const requestId = parseInt(row.dataset.requestId);
showRequestDetails(requestId);
});
});
}
function renderTimingBars(timing, scale) {
let html = '';
let currentOffset = timing.startTime * scale;
// DNS
if (timing.dns > 0) {
const width = timing.dns * scale / 1000;
html += `<div class="timing-bar bar-dns" style="left: ${currentOffset}px; width: ${width}px" title="DNS: ${timing.dns.toFixed(0)}ms"></div>`;
currentOffset += width;
}
// Connect
if (timing.connect > 0) {
const width = timing.connect * scale / 1000;
html += `<div class="timing-bar bar-connect" style="left: ${currentOffset}px; width: ${width}px" title="Connect: ${timing.connect.toFixed(0)}ms"></div>`;
currentOffset += width;
}
// SSL
if (timing.ssl > 0) {
const width = timing.ssl * scale / 1000;
html += `<div class="timing-bar bar-ssl" style="left: ${currentOffset}px; width: ${width}px" title="SSL: ${timing.ssl.toFixed(0)}ms"></div>`;
currentOffset += width;
}
// Wait (TTFB)
if (timing.wait > 0) {
const width = timing.wait * scale / 1000;
html += `<div class="timing-bar bar-wait" style="left: ${currentOffset}px; width: ${width}px" title="Wait: ${timing.wait.toFixed(0)}ms"></div>`;
currentOffset += width;
}
// Receive
const totalWidth = (timing.endTime - timing.startTime) * scale;
const receiveWidth = Math.max(totalWidth - (currentOffset - timing.startTime * scale), 0);
if (receiveWidth > 0) {
html += `<div class="timing-bar bar-receive" style="left: ${currentOffset}px; width: ${receiveWidth}px" title="Download: ${(receiveWidth / scale * 1000).toFixed(0)}ms"></div>`;
}
return html;
}
function showRequestDetails(requestId) {
const entry = currentHarData.entries.find(e => e.requestId === requestId);
if (!entry) return;
document.getElementById('dialogTitle').textContent = `Request #${requestId}`;
const content = `
<div class="detail-section">
<h3>General</h3>
<div class="detail-row">
<span class="detail-label">URL:</span>
<span class="detail-value">${entry.url}</span>
</div>
<div class="detail-row">
<span class="detail-label">Domain:</span>
<span class="detail-value">${entry.domain}</span>
</div>
<div class="detail-row">
<span class="detail-label">Method:</span>
<span class="detail-value">${entry.method}</span>
</div>
<div class="detail-row">
<span class="detail-label">Status:</span>
<span class="detail-value">${entry.status}</span>
</div>
<div class="detail-row">
<span class="detail-label">Type:</span>
<span class="detail-value">${entry.resourceType}</span>
</div>
<div class="detail-row">
<span class="detail-label">Protocol:</span>
<span class="detail-value">${entry.protocol || 'N/A'}</span>
</div>
<div class="detail-row">
<span class="detail-label">Priority:</span>
<span class="detail-value">${entry.priority || 'N/A'}</span>
</div>
${entry.isThirdParty ? '<div class="detail-row"><span class="detail-value" style="color: orange;">⚠️ Third-Party Resource</span></div>' : ''}
${entry.renderBlocking ? '<div class="detail-row"><span class="detail-value" style="color: red;">🚫 Render Blocking</span></div>' : ''}
</div>
<div class="detail-section">
<h3>Timing</h3>
${entry.timing.dns > 0 ? `<div class="detail-row"><span class="detail-label">DNS Lookup:</span><span class="detail-value">${entry.timing.dns.toFixed(2)} ms</span></div>` : ''}
${entry.timing.connect > 0 ? `<div class="detail-row"><span class="detail-label">Connection:</span><span class="detail-value">${entry.timing.connect.toFixed(2)} ms</span></div>` : ''}
${entry.timing.ssl > 0 ? `<div class="detail-row"><span class="detail-label">SSL:</span><span class="detail-value">${entry.timing.ssl.toFixed(2)} ms</span></div>` : ''}
${entry.timing.wait > 0 ? `<div class="detail-row"><span class="detail-label">Time to First Byte:</span><span class="detail-value">${entry.timing.wait.toFixed(2)} ms</span></div>` : ''}
<div class="detail-row">
<span class="detail-label">Total Time:</span>
<span class="detail-value">${entry.timing.total.toFixed(2)} ms</span>
</div>
</div>
<div class="detail-section">
<h3>Size</h3>
<div class="detail-row">
<span class="detail-label">Transfer Size:</span>
<span class="detail-value">${formatBytes(entry.size.transferSize)}</span>
</div>
<div class="detail-row">
<span class="detail-label">Resource Size:</span>
<span class="detail-value">${formatBytes(entry.size.resourceSize)}</span>
</div>
${entry.size.compressionRatio < 1 ? `
<div class="detail-row">
<span class="detail-label">Compression:</span>
<span class="detail-value">${(entry.size.compressionRatio * 100).toFixed(1)}% (${formatBytes(entry.size.resourceSize - entry.size.transferSize)} saved)</span>
</div>` : ''}
</div>
`;
document.getElementById('dialogContent').innerHTML = content;
document.getElementById('requestDialog').style.display = 'block';
document.getElementById('dialogOverlay').style.display = 'block';
}
function setupEventListeners() {
// Filter buttons
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.type;
renderWaterfall();
});
});
// Close dialog
document.getElementById('closeDialog').addEventListener('click', closeDialog);
document.getElementById('dialogOverlay').addEventListener('click', closeDialog);
}
function closeDialog() {
document.getElementById('requestDialog').style.display = 'none';
document.getElementById('dialogOverlay').style.display = 'none';
}
function truncateUrl(url, maxLength) {
if (url.length <= maxLength) return url;
const parts = url.split('/');
return parts[parts.length - 1].substring(0, maxLength) + '...';
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', init);