Files

799 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 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 + '%';
// Carbon Footprint
if (data.metrics.carbon) {
document.getElementById('carbon-card').style.display = 'flex';
document.getElementById('carbon-co2').textContent = data.metrics.carbon.co2 + 'g';
document.getElementById('carbon-rating').textContent = data.metrics.carbon.rating;
const isGreen = data.metrics.carbon.isGreen;
const badge = document.getElementById('carbon-badge');
badge.textContent = isGreen ? '🌱 Hosted Green' : '⛔ Non-Green Host';
badge.style.background = isGreen ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)';
badge.style.color = isGreen ? 'var(--color-accent-success)' : 'var(--color-accent-danger)';
}
// 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 = `
<a href="/reports/${data.id}.html" target="_blank" class="btn-secondary">
📄 View Full Report
</a>
`;
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();
// Data might exist on server even if filmstrip array is empty in this object
console.log("Opening images page for test:", data.id);
window.open(`/images.html?id=${data.id}`, '_blank');
};
}
// 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 => `
<div class="filmstrip-frame">
<img src="${frame.data}" alt="Timestamp: ${frame.timing}ms">
<div class="frame-time">${(frame.timing / 1000).toFixed(1)}s</div>
</div>
`).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 = '<h3>Recent Tests</h3>';
// Add comparison controls
container.innerHTML += `
<div id="comparison-controls" style="display: none; background: var(--color-bg-secondary); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
<span id="comparison-status" style="margin-right: 1rem; font-weight: 600;"></span>
<button id="compare-btn" class="btn-primary" style="padding: 0.5rem 1.5rem;" disabled>
Compare Selected Tests
</button>
<button id="clear-selection-btn" class="btn-secondary" style="padding: 0.5rem 1rem; margin-left: 0.5rem;">
Clear
</button>
</div>
`;
if (history.length === 0) {
container.innerHTML += '<p style="color: var(--color-text-tertiary)">No tests run yet.</p>';
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 = `
<div class="history-item">
<div style="display: flex; align-items: center; gap: 1rem; flex: 1;">
<input type="checkbox" class="compare-checkbox" data-test-id="${test.id}" style="width: 20px; height: 20px; cursor: pointer;">
<div style="flex: 1;">
<div style="font-weight: 600; font-size: 0.9rem">
<a href="#" class="history-url-link" data-test-id="${test.id}" style="color: var(--color-accent); text-decoration: none; cursor: pointer;" title="Click to reload test results">
${test.url}
</a>
</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted)">
${date}${test.isMobile ? '📱 Mobile' : '💻 Desktop'}
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<button class="btn-secondary rerun-btn" data-url="${test.url}" style="margin:0; padding: 0.25rem 0.75rem; font-size: 0.75rem;" title="Rerun this test">
🔄 Rerun
</button>
<a href="/waterfall.html?id=${test.id}" target="_blank" class="btn-secondary" style="margin:0; padding: 0.25rem 0.75rem; font-size: 0.75rem;" title="View Waterfall">
📊 Waterfall
</a>
<a href="/reports/${test.id}.html" target="_blank" class="btn-secondary" style="margin:0; padding: 0.25rem 0.75rem; font-size: 0.75rem;">
View Report
</a>
<a href="/vitals.html?id=${test.id}" class="btn-secondary" style="margin:0; padding: 0.25rem 0.75rem; font-size: 0.75rem;" title="Deep Dive Analysis">
⚡ Vitals
</a>
<div class="${colorClass}" style="font-weight: 700; font-size: 1.25rem">
${perfScore}
</div>
</div>
</div>
`;
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);
});
});
// Setup click handlers for Rerun buttons
document.querySelectorAll('.rerun-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const url = e.target.dataset.url;
// Populate URL field
document.getElementById('test-url').value = url;
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
// Trigger run (this ensures it uses the current "Number of Runs" setting)
document.getElementById('run-btn').click();
});
});
} 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;
}
// ============================================================================
// 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;
}
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 += `
<div style="border-left: 4px solid ${color}; padding: 1rem; margin: 0.5rem 0; background: var(--color-bg-tertiary); border-radius: 4px;">
<div style="font-weight: 600; margin-bottom: 0.5rem;">
${icon} ${check.title}
</div>
<div style="color: var(--color-text-secondary); font-size: 0.9rem;">
${check.description}
</div>
${check.savings ? `<div style="color: var(--color-accent); font-size: 0.85rem; margin-top: 0.5rem;">Potential savings: ${(check.savings / 1000).toFixed(1)}s</div>` : ''}
</div>
`;
});
if (data.checks.length === 0) {
html = '<p style="text-align: center; color: var(--color-text-secondary);">✅ All optimization checks passed!</p>';
}
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 += `<div style="margin-top: 0.5rem">Testing ${url} (${i+1}/${urls.length})...</div>`;
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 += `<div style="color: #4CAF50">✅ Complete</div>`;
} else {
progress.innerHTML += `<div style="color: #F44336">❌ Failed</div>`;
}
} catch (e) {
progress.innerHTML += `<div style="color: #F44336">❌ Error: ${e.message}</div>`;
}
}
progress.innerHTML += '<br><strong>Batch Completed!</strong>';
loadHistory(); // Refresh list
}