mirror of
https://github.com/DeNNiiInc/Web-Page-Performance-Test.git
synced 2026-04-17 20:05:58 +00:00
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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
48
deploy-sync-upgrade-b64.ps1
Normal file
48
deploy-sync-upgrade-b64.ps1
Normal 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
|
||||||
|
}
|
||||||
14
index.html
14
index.html
@@ -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
201
lib/har-parser.js
Normal 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
|
||||||
|
};
|
||||||
@@ -145,6 +145,12 @@ async function _executeTest(url, options) {
|
|||||||
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();
|
||||||
|
|
||||||
// Cleanup User Data Dir
|
// Cleanup User Data Dir
|
||||||
|
|||||||
10
main.js
10
main.js
@@ -108,6 +108,16 @@ function displayResults(data) {
|
|||||||
`;
|
`;
|
||||||
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');
|
||||||
|
|
||||||
// Scroll to results
|
// Scroll to results
|
||||||
|
|||||||
70
package-lock.json
generated
70
package-lock.json
generated
@@ -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
25
remote-debug.ps1
Normal 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
60
repro_concurrency.js
Normal 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
18
test-db.js
Normal 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
203
waterfall.html
Normal 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">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="dialogContent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="waterfall.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
230
waterfall.js
Normal file
230
waterfall.js
Normal 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);
|
||||||
Reference in New Issue
Block a user