// ============================================================================ // State & Config // ============================================================================ let currentDevice = 'desktop'; // ============================================================================ // UI Functions // ============================================================================ function setDevice(device) { currentDevice = device; document.querySelectorAll('.toggle-option').forEach(el => { el.classList.toggle('active', el.dataset.value === device); }); } function setLoading(isLoading) { const btn = document.getElementById('run-btn'); const spinner = document.getElementById('loading-spinner'); const btnText = btn.querySelector('span'); if (isLoading) { btn.disabled = true; spinner.style.display = 'block'; btnText.textContent = 'Running Test...'; } else { btn.disabled = false; spinner.style.display = 'none'; btnText.textContent = 'Run Test'; } } // ============================================================================ // API Handlers // ============================================================================ async function runTest() { console.log('Run Test triggered'); const urlInput = document.getElementById('test-url'); const url = urlInput.value.trim(); const captureFilmstrip = document.getElementById('capture-filmstrip')?.checked || false; const errorMsg = document.getElementById('error-msg'); const resultsArea = document.getElementById('results-area'); if (!url) { showError('Please enter a valid URL'); return; } try { new URL(url); } catch { showError('Invalid URL format (include http:// or https://)'); return; } // Reset UI errorMsg.style.display = 'none'; resultsArea.classList.remove('visible'); setLoading(true); try { const response = await fetch('/api/run-test', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-user-uuid': getUserUuid() }, body: JSON.stringify({ url: url, isMobile: currentDevice === 'mobile', runs: 1, captureFilmstrip: captureFilmstrip }) }); if (!response.ok) throw new Error('Test failed to start'); const data = await response.json(); displayResults(data); loadHistory(); // Refresh history } catch (error) { console.error(error); showError('Test execution failed. Check console for details.'); } finally { setLoading(false); } } function displayResults(data) { const resultsArea = document.getElementById('results-area'); // Calculate grades using grades.js if available, otherwise simplified logic let overallGrade = 'F'; let structureScore = 50; if (typeof calculateAllGrades === 'function') { // Assume grades.js available const grades = calculateAllGrades(data.metrics); // Map average score to grade? Simplified: // Use Performance Score as primary grade driver } const perfScore = Math.round(data.scores.performance); overallGrade = perfScore >= 90 ? 'A' : perfScore >= 80 ? 'B' : perfScore >= 70 ? 'C' : perfScore >= 60 ? 'D' : 'F'; // Structure Score (Average of non-perf categories) structureScore = Math.round(( (data.scores.seo || 0) + (data.scores.bestPractices || data.scores['best-practices'] || 0) + (data.scores.accessibility || 0) ) / 3); // Update Dashboard UI const gradeCircle = document.getElementById('overall-grade'); const gradeLetter = gradeCircle.querySelector('.grade-letter'); // Animate Grade gradeLetter.textContent = overallGrade; gradeCircle.className = 'grade-circle grade-' + overallGrade.toLowerCase(); document.getElementById('performance-score').textContent = perfScore + '%'; document.getElementById('structure-score').textContent = structureScore + '%'; // Web Vitals const lcpVal = data.metrics.lcp < 1000 ? (data.metrics.lcp/1000).toFixed(2) + 's' : Math.round(data.metrics.lcp) + 'ms'; const tbtVal = Math.round(data.metrics.tbt) + 'ms'; const clsVal = data.metrics.cls.toFixed(2); document.getElementById('vital-lcp').textContent = lcpVal; document.getElementById('vital-tbt').textContent = tbtVal; document.getElementById('vital-cls').textContent = clsVal; // Display Filmstrip if (data.filmstrip && data.filmstrip.length > 0) { displayFilmstrip(data.filmstrip); } else { document.getElementById('filmstrip-section').style.display = 'none'; } // Remove existing actions if any const existingActions = resultsArea.querySelector('.report-actions'); if (existingActions) existingActions.remove(); // Add Report Button const actionsDiv = document.createElement('div'); actionsDiv.className = 'report-actions'; actionsDiv.innerHTML = ` đ View Full Report `; 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'); }; document.getElementById('view-video').onclick = (e) => { e.preventDefault(); if (data.filmstrip && data.filmstrip.length > 0) { openVideoModal(data.filmstrip); } else { alert('No video data available for this test.'); } }; document.getElementById('view-images').onclick = (e) => { e.preventDefault(); if (data.filmstrip && data.filmstrip.length > 0) { window.open(`/images.html?id=${data.id}`, '_blank'); } else { alert('No images available.'); } }; } // Load content breakdown if (typeof renderContentBreakdown === 'function') { renderContentBreakdown(data.id); } // Load and display optimizations loadOptimizations(data.id); // Wire export buttons document.getElementById('export-buttons').style.display = 'block'; document.getElementById('export-har').href = `/api/export/${data.id}/har`; document.getElementById('export-csv').href = `/api/export/${data.id}/csv`; resultsArea.classList.add('visible'); // Scroll to results resultsArea.scrollIntoView({ behavior: 'smooth' }); } function displayFilmstrip(items) { const section = document.getElementById('filmstrip-section'); const container = document.getElementById('filmstrip-container'); section.style.display = 'block'; // Filter/Sample items if too many const frames = items; container.innerHTML = frames.map(frame => `
No tests run yet.
'; return; } history.slice(0, 10).forEach(test => { const date = new Date(test.timestamp).toLocaleString(); const perfScore = Math.round(test.scores.performance); const colorClass = perfScore >= 90 ? 'score-good' : (perfScore >= 50 ? 'score-average' : 'score-poor'); const html = `| Run | Performance | LCP (ms) | CLS | TBT (ms) | Actions |
|---|---|---|---|---|---|
| #${run.run_number} ${run.is_median ? 'â Median' : ''} | - | - | - | - | View Details |
| No run data available | |||||
â All optimization checks passed!
'; } itemsEl.innerHTML = html; container.style.display = 'block'; } catch (error) { console.error('Failed to load optimizations:', error); } } // Video Player State let videoFrames = []; let isPlaying = false; let currentFrameIndex = 0; let videoInterval = null; function openVideoModal(frames) { if (!frames || frames.length === 0) return; videoFrames = frames; currentFrameIndex = 0; isPlaying = false; document.getElementById('video-modal').style.display = 'block'; updateVideoFrame(); } function closeVideoModal() { stopVideo(); document.getElementById('video-modal').style.display = 'none'; } function toggleVideoPlay() { if (isPlaying) { stopVideo(); } else { playVideo(); } } function playVideo() { if (isPlaying) return; isPlaying = true; document.getElementById('video-play-btn').textContent = '⸠Pause'; if (currentFrameIndex >= videoFrames.length - 1) { currentFrameIndex = 0; } videoInterval = setInterval(() => { currentFrameIndex++; if (currentFrameIndex >= videoFrames.length) { stopVideo(); return; } updateVideoFrame(); }, 100); // 10fps } function stopVideo() { isPlaying = false; document.getElementById('video-play-btn').textContent = 'âļ Play'; if (videoInterval) clearInterval(videoInterval); } function updateVideoFrame() { const frame = videoFrames[currentFrameIndex]; document.getElementById('video-img').src = frame.data; document.getElementById('video-time').textContent = (frame.timing / 1000).toFixed(1) + 's'; const progress = ((currentFrameIndex + 1) / videoFrames.length) * 100; document.getElementById('video-progress-fill').style.width = `${progress}%`; } async function downloadVideo() { if (!videoFrames || videoFrames.length === 0) { alert('No video data to download'); return; } const downloadBtn = document.getElementById('video-download-btn'); downloadBtn.disabled = true; downloadBtn.textContent = 'âŗ Compiling...'; try { // Initialize Whammy video encoder with 30 FPS const fps = 30; const encoder = new Whammy(fps); // Create canvas for high-quality rendering (1920x1080) const canvas = document.createElement('canvas'); canvas.width = 1920; canvas.height = 1080; const ctx = canvas.getContext('2d'); // Calculate total duration from the last frame's timing const totalDuration = videoFrames[videoFrames.length - 1].timing; console.log(`Compiling video: ${videoFrames.length} source frames, total ${totalDuration}ms`); // We will generate a frame for every 1/30th of a second const frameInterval = 1000 / fps; // ~33.33ms let totalOutputFrames = Math.ceil(totalDuration / frameInterval); // Ensure at least one frame if duration is 0 or very small if (totalOutputFrames <= 0) totalOutputFrames = 1; console.log(`Generating ${totalOutputFrames} output frames`); // Pre-load all images const loadedImages = await Promise.all(videoFrames.map(async frame => { const img = new Image(); img.crossOrigin = 'anonymous'; return new Promise((resolve, reject) => { img.onload = () => resolve({ img, timing: frame.timing }); img.onerror = () => resolve(null); // Skip failed frames img.src = frame.data; }); })); const validImages = loadedImages.filter(i => i !== null); if (validImages.length === 0) { throw new Error("Failed to load any source images"); } // Generate video frames for (let i = 0; i < totalOutputFrames; i++) { const currentTime = i * frameInterval; // Find the image that should be displayed at this time // It's the latest image whose timing is <= currentTime let currentImage = validImages[0]; for (let j = 0; j < validImages.length; j++) { if (validImages[j].timing <= currentTime) { currentImage = validImages[j]; } else { break; } } // Draw frame // Clear and draw black background ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Center and scale image (Contain) if (currentImage && currentImage.img) { const scale = Math.min(canvas.width / currentImage.img.width, canvas.height / currentImage.img.height); const x = (canvas.width - currentImage.img.width * scale) / 2; const y = (canvas.height - currentImage.img.height * scale) / 2; // Use high quality image smoothing ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(currentImage.img, x, y, currentImage.img.width * scale, currentImage.img.height * scale); } // Add timestamp overlay (crisp text) ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(20, canvas.height - 70, 220, 50); ctx.fillStyle = '#fff'; ctx.font = 'bold 32px Arial'; ctx.fillText(`${(currentTime / 1000).toFixed(1)}s`, 40, canvas.height - 35); // Add frame to encoder // Note: add() might parse the webp immediately, so this must happen encoder.add(canvas); // Yield to UI thread occasionally to prevent freezing if (i % 15 === 0) await new Promise(r => setTimeout(r, 0)); } // Compile and download const outputBlob = encoder.compile(); const url = URL.createObjectURL(outputBlob); // Trigger download const a = document.createElement('a'); a.href = url; a.download = `page-load-${Date.now()}.webm`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('Video download error:', error); alert(`Failed to create video: ${error.message}\n\nYour browser may not support this feature. Try using Chrome or Edge.`); } finally { downloadBtn.disabled = false; downloadBtn.textContent = 'âŦ Download'; } } // Initialization document.addEventListener('DOMContentLoaded', () => { // Ensure we have an identity const userUuid = getUserUuid(); console.log('User Identity:', userUuid); updateVersionBadge(); loadHistory(); // Attach event listener programmatically const runBtn = document.getElementById('run-btn'); if (runBtn) { runBtn.addEventListener('click', runTest); console.log('Run Test button listener attached'); } else { console.error('Run Test button not found'); } // Auto-refresh Git badge setInterval(updateVersionBadge, 5 * 60 * 1000); }); // ============================================================================ // Extra Features (Diagnostics & Bulk) // ============================================================================ function toggleSection(id) { const el = document.getElementById(id); el.style.display = el.style.display === 'none' ? 'block' : 'none'; } async function runTraceroute() { const host = document.getElementById('trace-host').value; const out = document.getElementById('trace-output'); if (!host) return; out.style.display = 'block'; out.textContent = 'Running traceroute...'; try { const res = await fetch(`/api/traceroute?host=${host}`); const data = await res.json(); out.textContent = data.output; } catch (e) { out.textContent = 'Error: ' + e.message; } } async function runBulkTest() { const text = document.getElementById('bulk-urls').value; const urls = text.split('\n').map(u => u.trim()).filter(u => u); if (urls.length === 0) { alert('No URLs provided'); return; } const progress = document.getElementById('bulk-progress'); progress.innerHTML = `Starting batch of ${urls.length} tests...`; // Simple Frontend orchestration for (let i = 0; i < urls.length; i++) { const url = urls[i]; progress.innerHTML += `