// ============================================================================ // 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, url: url, isMobile: currentDevice === 'mobile', 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) { // Open images.html?id=... (Assuming logic exists, user requested it back) // Or reusing filmstrip display? User said "View Images function". // Checking previous code: it opened `/images.html?id=${data.id}` 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 => `
Timestamp: ${frame.timing}ms
${(frame.timing / 1000).toFixed(1)}s
`).join(''); } function showError(msg) { const el = document.getElementById('error-msg'); el.textContent = msg; el.style.display = 'block'; } async function loadHistory() { try { const response = await fetch('/api/history', { headers: { 'x-user-uuid': getUserUuid() } }); const history = await response.json(); const container = document.getElementById('history-list'); container.innerHTML = '

Recent Tests

'; // Add comparison controls container.innerHTML += ` `; if (history.length === 0) { container.innerHTML += '

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 = `
${test.url}
${date} â€ĸ ${test.isMobile ? '📱 Mobile' : 'đŸ’ģ Desktop'}
📊 Waterfall View Report
${perfScore}
`; container.innerHTML += html; }); // Setup comparison functionality setupComparisonControls(); // Setup click handlers for history URLs document.querySelectorAll('.history-url-link').forEach(link => { link.addEventListener('click', async (e) => { e.preventDefault(); const testId = e.target.dataset.testId; await loadTestById(testId); }); }); } catch (error) { console.error('Failed to load history', error); } } // Load and display a test by ID async function loadTestById(testId) { try { const response = await fetch(`/reports/${testId}.json`); if (!response.ok) throw new Error('Test not found'); const data = await response.json(); displayResults(data); // Scroll to results document.getElementById('results-area').scrollIntoView({ behavior: 'smooth' }); } catch (error) { console.error('Failed to load test:', error); alert('Could not load test results. The test may have been deleted.'); } } function setupComparisonControls() { const checkboxes = document.querySelectorAll('.compare-checkbox'); const controls = document.getElementById('comparison-controls'); const status = document.getElementById('comparison-status'); const compareBtn = document.getElementById('compare-btn'); const clearBtn = document.getElementById('clear-selection-btn'); function updateComparisonStatus() { const selected = Array.from(checkboxes).filter(cb => cb.checked); if (selected.length === 0) { controls.style.display = 'none'; } else { controls.style.display = 'block'; status.textContent = `${selected.length} test${selected.length > 1 ? 's' : ''} selected`; compareBtn.disabled = selected.length !== 2; if (selected.length > 2) { // Uncheck oldest selections selected[0].checked = false; updateComparisonStatus(); } } } checkboxes.forEach(cb => { cb.addEventListener('change', updateComparisonStatus); }); compareBtn.addEventListener('click', () => { const selected = Array.from(checkboxes).filter(cb => cb.checked); if (selected.length === 2) { const test1 = selected[0].dataset.testId; const test2 = selected[1].dataset.testId; window.open(`/compare.html?test1=${test1}&test2=${test2}`, '_blank'); } }); clearBtn.addEventListener('click', () => { checkboxes.forEach(cb => cb.checked = false); updateComparisonStatus(); }); } // ============================================================================ // Git Version Badge // ============================================================================ async function updateVersionBadge() { try { const response = await fetch('/api/git-info'); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); const commitIdEl = document.getElementById('commit-id'); const commitAgeEl = document.getElementById('commit-age'); if (data.error || !data.commitId) { commitIdEl.textContent = 'local'; commitAgeEl.textContent = 'dev mode'; commitIdEl.style.color = 'var(--color-text-muted)'; } else { commitIdEl.textContent = data.commitId; commitAgeEl.textContent = data.commitAge; commitIdEl.style.color = 'var(--color-accent-success)'; } } catch (error) { console.error('Failed to fetch git info:', error); const commitIdEl = document.getElementById('commit-id'); const commitAgeEl = document.getElementById('commit-age'); commitIdEl.textContent = 'local'; commitAgeEl.textContent = 'dev mode'; commitIdEl.style.color = 'var(--color-text-tertiary)'; } } // ============================================================================ // Identity Management // ============================================================================ function getUserUuid() { let uuid = localStorage.getItem('user-uuid'); if (!uuid) { uuid = 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); localStorage.setItem('user-uuid', uuid); } return uuid; } // Multi-run progress polling async function pollSuiteStatus(suiteId, totalRuns) { const pollInterval = setInterval(async () => { try { const response = await fetch(`/api/suite-status/${suiteId}`); const suite = await response.json(); // Update progress const progress = (suite.completed_runs / suite.run_count) * 100; document.getElementById('progress-fill').style.width = `${progress}%`; document.getElementById('progress-text').textContent = `Run ${suite.completed_runs} of ${suite.run_count} completed...`; // Check if complete if (suite.status === 'completed') { clearInterval(pollInterval); document.getElementById('progress-text').textContent = 'Calculating statistics...'; // Display multi-run results displayMultiRunResults(suite); document.getElementById('multi-run-progress').style.display = 'none'; } else if (suite.status === 'failed') { clearInterval(pollInterval); throw new Error('Some test runs failed'); } } catch (error) { clearInterval(pollInterval); console.error('Polling error:', error); document.getElementById('error-msg').textContent = 'Error tracking progress: ' + error.message; document.getElementById('error-msg').style.display = 'block'; document.getElementById('multi-run-progress').style.display = 'none'; } }, 2000); // Poll every 2 seconds } function displayMultiRunResults(suite) { // Show statistics summary const statsHtml = `
${suite.median_performance_score?.toFixed(0) || 'N/A'}
Median Performance
${suite.avg_performance_score?.toFixed(0) || 'N/A'}
Average Performance
Âą${suite.stddev_performance_score?.toFixed(1) || 'N/A'}
Std Deviation

Individual Runs

${suite.runs?.map(run => ` `).join('') || ''}
Run Performance LCP (ms) CLS TBT (ms) Actions
#${run.run_number} ${run.is_median ? '⭐ Median' : ''} - - - - View Details
No run data available
`; document.getElementById('results-area').innerHTML = statsHtml; document.getElementById('results-area').style.display = 'block'; } function getUserUuid() { let uuid = localStorage.getItem('user_uuid'); if (!uuid) { uuid = crypto.randomUUID(); localStorage.setItem('user_uuid', uuid); } return uuid; } async function loadOptimizations(testId) { try { const response = await fetch(`/reports/${testId}.optimizations.json`); if (!response.ok) throw new Error('Optimizations not found'); const data = await response.json(); const container = document.getElementById('optimization-checklist'); const scoreEl = document.getElementById('optimization-score'); const itemsEl = document.getElementById('optimization-items'); // Display score const score = data.summary.score; scoreEl.textContent = `${score}%`; scoreEl.style.color = score >= 80 ? '#4CAF50' : score >= 50 ? '#FFC107' : '#F44336'; // Display checks let html = ''; data.checks.forEach(check => { const icon = check.status === 'error' ? '❌' : check.status === 'warning' ? 'âš ī¸' : 'â„šī¸'; const color = check.status === 'error' ? '#F44336' : check.status === 'warning' ? '#FFC107' : '#2196F3'; html += `
${icon} ${check.title}
${check.description}
${check.savings ? `
Potential savings: ${(check.savings / 1000).toFixed(1)}s
` : ''}
`; }); if (data.checks.length === 0) { html = '

✅ 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}%`; } // 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 += `
Testing ${url} (${i+1}/${urls.length})...
`; try { // Re-use existing runTest API // Note: We need a way to reuse the run logic without clicking buttons // Manually calling fetch here duplicating runTest logic for simplicity 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', captureFilmstrip: false // Disable filmstrip for bulk to save speed? Or keep it? }) }); if (response.ok) { progress.innerHTML += `
✅ Complete
`; } else { progress.innerHTML += `
❌ Failed
`; } } catch (e) { progress.innerHTML += `
❌ Error: ${e.message}
`; } } progress.innerHTML += '
Batch Completed!'; loadHistory(); // Refresh list }