/** * Waterfall Chart Renderer * Visualizes network requests timeline with interactive details */ let currentHarData = null; let currentFilter = 'all'; let currentSort = 'time-desc'; // Default: slowest to fastest let currentViewMode = 'waterfall'; // waterfall or connection // 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 = '

Error: No test ID provided

'; 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 = `

Error loading waterfall data: ${error.message}

`; } } function renderWaterfall() { const canvas = document.getElementById('waterfallCanvas'); const entries = currentHarData.entries; if (!entries || entries.length === 0) { canvas.innerHTML = '

No requests found

'; return; } if (currentViewMode === 'connection') { renderConnectionView(entries); return; } // Original waterfall view // 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); // Sort entries based on current sort sortEntries(filteredEntries); let html = ''; // Add time scale header with grid lines html += '
'; for (let sec = 0; sec <= Math.ceil(maxTime); sec++) { const pos = sec * scale; html += `
${sec}s
`; html += `
`; } html += '
'; filteredEntries.forEach((entry, index) => { const label = truncateUrl(entry.url, 30); const timingBars = renderTimingBars(entry.timing, scale); const statusColor = getStatusColor(entry.status); const typeBadge = getResourceTypeBadge(entry.resourceType); const sizeKB = (entry.size.transferSize / 1024).toFixed(1); const timeMS = entry.timing.total.toFixed(0); html += `
${entry.requestId}
${entry.status}
${typeBadge}
${label}
${timingBars}
${sizeKB} KB
${timeMS} ms
`; }); canvas.innerHTML = html; // Attach click handlers document.querySelectorAll('.waterfall-row').forEach(row => { row.addEventListener('click', () => { const requestId = parseInt(row.dataset.requestId); showRequestDetails(requestId); }); }); // Render details table renderDetailsTable(filteredEntries); } function renderConnectionView(entries) { const canvas = document.getElementById('waterfallCanvas'); // Group requests by connection/socket const connections = groupByConnection(entries); if (connections.length === 0) { canvas.innerHTML = '

No connection information available

'; return; } // Calculate timeline scale const maxTime = Math.max(...entries.map(e => e.timing.endTime)); const scale = 1000 / maxTime; let html = ''; // Add time scale header html += '
'; for (let sec = 0; sec <= Math.ceil(maxTime); sec++) { const pos = sec * scale; html += `
${sec}s
`; html += `
`; } html += '
'; // Render each connection as a group connections.forEach((conn, connIndex) => { const domain = conn.domain || 'Unknown'; const connLabel = `Connection ${connIndex + 1}: ${domain}`; const reqCount = conn.requests.length; const reused = conn.reused ? '♻️ Reused' : '🆕 New'; html += `
${connLabel} ${reused} ${reqCount} requests
`; conn.requests.forEach(entry => { const label = truncateUrl(entry.url, 25); const timingBars = renderTimingBars(entry.timing, scale); const statusColor = getStatusColor(entry.status); const typeBadge = getResourceTypeBadge(entry.resourceType); html += `
${entry.requestId}
${entry.status}
${typeBadge}
${label}
${timingBars}
`; }); html += `
`; }); canvas.innerHTML = html; // Attach click handlers document.querySelectorAll('.waterfall-row').forEach(row => { row.addEventListener('click', () => { const requestId = parseInt(row.dataset.requestId); showRequestDetails(requestId); }); }); // Render details table with all requests const allRequests = connections.flatMap(c => c.requests); renderDetailsTable(allRequests); } function groupByConnection(entries) { const connectionMap = new Map(); entries.forEach(entry => { // Use socket ID or create connection based on domain + protocol const connKey = entry.socket || `${entry.domain}-${entry.protocol || 'http'}`; if (!connectionMap.has(connKey)) { connectionMap.set(connKey, { id: connKey, domain: entry.domain, protocol: entry.protocol, reused: entry.connectionReused, requests: [] }); } connectionMap.get(connKey).requests.push(entry); }); // Convert to array and sort by first request time return Array.from(connectionMap.values()) .sort((a, b) => a.requests[0].timing.startTime - b.requests[0].timing.startTime); } function renderDetailsTable(entries) { const container = document.getElementById('requestDetailsTable'); let html = ` `; entries.forEach(entry => { const statusColor = getStatusColor(entry.status); const sizeKB = (entry.size.transferSize / 1024).toFixed(1); const timeMS = entry.timing.total.toFixed(0); html += ` `; }); html += `
# URL Status Type Method Size Time Protocol Priority
${entry.requestId} ${truncateUrl(entry.url, 60)} ${entry.status} ${getResourceTypeBadgeText(entry.resourceType)} ${entry.method} ${sizeKB} KB ${timeMS} ms ${entry.protocol || 'N/A'} ${entry.priority || 'N/A'}
`; container.innerHTML = html; // Add click handlers to table rows container.querySelectorAll('tbody tr').forEach(row => { row.style.cursor = 'pointer'; row.addEventListener('click', () => { const requestId = parseInt(row.dataset.requestId); showRequestDetails(requestId); }); }); // Add sort handlers to headers setupTableSort(); } function getResourceTypeBadgeText(type) { const badges = { 'Document': 'HTML', 'Stylesheet': 'CSS', 'Script': 'JavaScript', 'Image': 'Image', 'Font': 'Font', 'XHR': 'XHR', 'Fetch': 'Fetch' }; return badges[type] || type; } function setupTableSort() { let currentTableSort = { column: null, ascending: true }; document.querySelectorAll('.details-table th[data-sort]').forEach(header => { header.addEventListener('click', () => { const column = header.dataset.sort; // Toggle sort direction if same column if (currentTableSort.column === column) { currentTableSort.ascending = !currentTableSort.ascending; } else { currentTableSort.column = column; currentTableSort.ascending = true; } // Update header styles document.querySelectorAll('.details-table th').forEach(h => { h.classList.remove('sorted'); h.querySelector('.sort-icon').textContent = '▼'; }); header.classList.add('sorted'); header.querySelector('.sort-icon').textContent = currentTableSort.ascending ? '▲' : '▼'; // Sort and re-render sortTableBy(column, currentTableSort.ascending); }); }); } function sortTableBy(column, ascending) { const entries = [...currentHarData.entries]; const filteredEntries = currentFilter === 'all' ? entries : entries.filter(e => e.resourceType === currentFilter); filteredEntries.sort((a, b) => { let valA, valB; switch(column) { case 'id': valA = a.requestId; valB = b.requestId; break; case 'url': valA = a.url.toLowerCase(); valB = b.url.toLowerCase(); return ascending ? valA.localeCompare(valB) : valB.localeCompare(valA); case 'status': valA = a.status; valB = b.status; break; case 'type': valA = a.resourceType; valB = b.resourceType; return ascending ? valA.localeCompare(valB) : valB.localeCompare(valA); case 'method': valA = a.method; valB = b.method; return ascending ? valA.localeCompare(valB) : valB.localeCompare(valA); case 'size': valA = a.size.transferSize; valB = b.size.transferSize; break; case 'time': valA = a.timing.total; valB = b.timing.total; break; case 'protocol': valA = a.protocol || ''; valB = b.protocol || ''; return ascending ? valA.localeCompare(valB) : valB.localeCompare(valA); case 'priority': valA = a.priority || ''; valB = b.priority || ''; return ascending ? valA.localeCompare(valB) : valB.localeCompare(valA); default: return 0; } return ascending ? valA - valB : valB - valA; }); renderDetailsTable(filteredEntries); } function getStatusColor(status) { if (status >= 200 && status < 300) return '#4CAF50'; // Green if (status >= 300 && status < 400) return '#FF9800'; // Orange if (status >= 400 && status < 500) return '#F44336'; // Red if (status >= 500) return '#B71C1C'; // Dark Red return '#9E9E9E'; // Grey for others } function getResourceTypeBadge(type) { const badges = { 'Document': 'HTML', 'Stylesheet': 'CSS', 'Script': 'JS', 'Image': 'IMG', 'Font': 'FONT', 'XHR': 'XHR', 'Fetch': 'API' }; return badges[type] || 'OTHER'; } function sortEntries(entries) { switch(currentSort) { case 'time-desc': // Slowest to fastest entries.sort((a, b) => b.timing.total - a.timing.total); break; case 'time-asc': // Fastest to slowest entries.sort((a, b) => a.timing.total - b.timing.total); break; case 'size-desc': // Largest to smallest entries.sort((a, b) => b.size.transferSize - a.size.transferSize); break; case 'size-asc': // Smallest to largest entries.sort((a, b) => a.size.transferSize - b.size.transferSize); break; case 'name-asc': // A to Z entries.sort((a, b) => a.url.localeCompare(b.url)); break; case 'name-desc': // Z to A entries.sort((a, b) => b.url.localeCompare(a.url)); break; case 'sequence': // Original sequence entries.sort((a, b) => a.requestId - b.requestId); break; } } function renderTimingBars(timing, scale) { let html = ''; let currentOffset = timing.startTime * scale; // DNS if (timing.dns > 0) { const width = timing.dns * scale / 1000; html += `
`; currentOffset += width; } // Connect if (timing.connect > 0) { const width = timing.connect * scale / 1000; html += `
`; currentOffset += width; } // SSL if (timing.ssl > 0) { const width = timing.ssl * scale / 1000; html += `
`; currentOffset += width; } // Wait (TTFB) if (timing.wait > 0) { const width = timing.wait * scale / 1000; html += `
`; currentOffset += width; } // Receive const totalWidth = (timing.endTime - timing.startTime) * scale; const receiveWidth = Math.max(totalWidth - (currentOffset - timing.startTime * scale), 0); if (receiveWidth > 0) { html += `
`; } 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 = `

General

URL: ${entry.url}
Domain: ${entry.domain}
Method: ${entry.method}
Status: ${entry.status}
Type: ${entry.resourceType}
Protocol: ${entry.protocol || 'N/A'}
Priority: ${entry.priority || 'N/A'}
${entry.isThirdParty ? '
⚠️ Third-Party Resource
' : ''} ${entry.renderBlocking ? '
🚫 Render Blocking
' : ''}

Timing

${entry.timing.dns > 0 ? `
DNS Lookup:${entry.timing.dns.toFixed(2)} ms
` : ''} ${entry.timing.connect > 0 ? `
Connection:${entry.timing.connect.toFixed(2)} ms
` : ''} ${entry.timing.ssl > 0 ? `
SSL:${entry.timing.ssl.toFixed(2)} ms
` : ''} ${entry.timing.wait > 0 ? `
Time to First Byte:${entry.timing.wait.toFixed(2)} ms
` : ''}
Total Time: ${entry.timing.total.toFixed(2)} ms

Size

Transfer Size: ${formatBytes(entry.size.transferSize)}
Resource Size: ${formatBytes(entry.size.resourceSize)}
${entry.size.compressionRatio < 1 ? `
Compression: ${(entry.size.compressionRatio * 100).toFixed(1)}% (${formatBytes(entry.size.resourceSize - entry.size.transferSize)} saved)
` : ''}

Request Headers

Response Headers

`; 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(); }); }); // View mode selector document.getElementById('viewMode').addEventListener('change', (e) => { currentViewMode = e.target.value; // Update heading document.querySelector('.waterfall-container h1').textContent = currentViewMode === 'connection' ? 'Connection View' : 'Request Waterfall'; renderWaterfall(); }); // Sort dropdown document.getElementById('sortSelect').addEventListener('change', (e) => { currentSort = e.target.value; 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]; } function formatHeaders(headers) { if (!headers || Object.keys(headers).length === 0) { return '

No headers available

'; } let html = '
'; Object.entries(headers).forEach(([key, value]) => { html += `
${key}: ${value}
`; }); html += '
'; return html; } function toggleHeaderSection(sectionId) { const content = document.getElementById(`${sectionId}-content`); const icon = document.getElementById(`${sectionId}-icon`); if (content.style.display === 'none') { content.style.display = 'block'; icon.textContent = '▲'; } else { content.style.display = 'none'; icon.textContent = '▼'; } } // Initialize on page load document.addEventListener('DOMContentLoaded', init);