V2 Feature overhaul and improvements

This commit is contained in:
2025-12-29 12:12:38 +11:00
parent a8f64580c7
commit 65383788b1
15 changed files with 1851 additions and 386 deletions

View File

@@ -3,105 +3,145 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Comparison - Web Page Performance Test</title>
<title>Visual Comparison - Web Page Performance Test</title>
<link rel="icon" type="image/png" href="Logo.png">
<link rel="stylesheet" href="styles.css?v=3.0">
<link rel="stylesheet" href="styles.css?v=2.2">
<style>
.comparison-container {
max-width: 1600px;
margin: 2rem auto;
padding: 0 1rem;
}
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-top: 2rem;
}
.metric-diff {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 0.5rem;
border-bottom: 1px solid var(--color-border);
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.diff-better { color: var(--color-accent-success); font-weight: bold; }
.diff-worse { color: var(--color-accent-error); font-weight: bold; }
.diff-neutral { color: var(--color-text-secondary); }
.filmstrip-row {
.video-wrapper {
margin-top: 2rem;
border: 2px solid var(--color-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
background: #000;
}
video {
max-width: 100%;
display: block;
}
.labels {
display: flex;
gap: 0.5rem;
overflow-x: auto;
width: 100%;
justify-content: space-around;
padding: 1rem 0;
font-size: 1.2rem;
font-weight: bold;
color: var(--color-text-primary);
}
.filmstrip-img {
height: 100px;
border: 1px solid var(--color-border);
.loading-state {
margin-top: 4rem;
text-align: center;
}
.spinner {
width: 50px;
height: 50px;
border: 5px solid rgba(255,255,255,0.1);
border-top: 5px solid var(--color-accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem auto;
}
@keyframes spin { 100% { transform: rotate(360deg); } }
</style>
</head>
<body>
<header>
<div class="logo-container">
<img src="Logo.png" alt="Logo" class="logo">
<h1>Test Comparison</h1>
</div>
<a href="/" class="btn-secondary">← Back to Dashboard</a>
</header>
<div class="container">
<header class="header">
<h1 class="title">Visual Comparison</h1>
<p class="subtitle">Side-by-Side Video Analysis</p>
</header>
<div class="comparison-container">
<div id="loading" style="text-align: center; padding: 2rem;">Loading comparison...</div>
<div id="comparison-content" style="display: none;">
<div class="comparison-grid">
<!-- Test A -->
<div class="test-column" id="col-a">
<h2 id="title-a">Test A</h2>
<div class="card">
<div id="meta-a"></div>
<div class="grade-circle" id="grade-a">
<span class="grade-letter"></span>
</div>
</div>
<div id="filmstrip-a" class="filmstrip-row"></div>
</div>
<!-- Test B -->
<div class="test-column" id="col-b">
<h2 id="title-b">Test B</h2>
<div class="card">
<div id="meta-b"></div>
<div class="grade-circle" id="grade-b">
<span class="grade-letter"></span>
</div>
</div>
<div id="filmstrip-b" class="filmstrip-row"></div>
</div>
<div class="comparison-container">
<div id="loading" class="loading-state">
<div class="spinner"></div>
<h2>Generating Comparison Video...</h2>
<p style="color: var(--color-text-tertiary)">This requires processing video frames on the server. Please wait.</p>
</div>
<!-- Metrics Diff Table -->
<div class="card" style="margin-top: 2rem;">
<h3>Metrics Comparison</h3>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th>Metric</th>
<th>Test A</th>
<th>Test B</th>
<th>Difference</th>
</tr>
</thead>
<tbody id="metrics-body"></tbody>
</table>
<div id="result" style="display: none; width: 100%;">
<div class="labels">
<div id="label-left">Test A</div>
<div id="label-right">Test B</div>
</div>
<div class="video-wrapper">
<video id="comparison-video" controls autoplay loop playsinline>
Your browser does not support the video tag.
</video>
</div>
<div style="text-align: center; margin-top: 2rem;">
<a href="/" class="btn-secondary">⬅ Back to Dashboard</a>
<a href="#" id="download-link" class="btn-primary" download="comparison.mp4">⬇ Download Video</a>
</div>
</div>
<div id="error" style="display: none; color: var(--color-accent-danger); text-align: center; margin-top: 2rem;">
<h3>Error Generating Comparison</h3>
<p id="error-msg"></p>
<a href="/" class="btn-secondary" style="margin-top: 1rem;">Back</a>
</div>
</div>
</div>
<script src="compare.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const params = new URLSearchParams(window.location.search);
const test1 = params.get('test1');
const test2 = params.get('test2');
if (!test1 || !test2) {
showError('Missing test IDs in URL parameters.');
return;
}
// Update Labels (Optional: fetch test details to show URLs)
// For now just show IDs or generic labels
document.getElementById('label-left').textContent = `Test ${test1.substring(0, 8)}...`;
document.getElementById('label-right').textContent = `Test ${test2.substring(0, 8)}...`;
try {
// Fetch info to get URLs (optional enhancement)
// const [res1, res2] = await Promise.all([fetch('/reports/'+test1+'.json'), fetch('/reports/'+test2+'.json')]);
// ... logic to get URLs ...
const videoSrc = `/api/compare?test1=${test1}&test2=${test2}`;
const videoEl = document.getElementById('comparison-video');
// We set the src. The browser will try to load it.
// Since the API generates it on the fly, it might take a moment before the first byte.
// However, <video> tag handles buffering.
// But if the server takes 10s to RESPOND, the browser might timeout or show error.
// Better approach: fetch the blob, then show.
const response = await fetch(videoSrc);
if (!response.ok) throw new Error('Server failed to generate video.');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
videoEl.src = url;
document.getElementById('download-link').href = url;
document.getElementById('loading').style.display = 'none';
document.getElementById('result').style.display = 'block';
} catch (e) {
showError(e.message);
}
});
function showError(msg) {
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'block';
document.getElementById('error-msg').textContent = msg;
}
</script>
</body>
</html>

View File

@@ -41,6 +41,19 @@ fi
if ! command -v git &> /dev/null; then
echo "📦 Installing git..."
apt-get update && apt-get install -y git
apt-get update && apt-get install -y git
fi
# Install ffmpeg if not present
if ! command -v ffmpeg &> /dev/null; then
echo "📦 Installing ffmpeg..."
apt-get update && apt-get install -y ffmpeg
fi
# Install traceroute if not present
if ! command -v traceroute &> /dev/null; then
echo "📦 Installing traceroute..."
apt-get update && apt-get install -y traceroute
fi
# Create app directory

View File

@@ -28,6 +28,10 @@
</svg>
Watch on YouTube @beyondcloudtechnology
</a>
<!-- Navigation -->
<nav style="margin-top: 0.5rem;">
<a href="traceroute.html" class="button" style="color:var(--color-text-primary); text-decoration:none; margin-right: 1rem;">🌍 Network Trace</a>
</nav>
</div>
<div class="header-controls">
<!-- Theme toggle or other controls can go here -->
@@ -106,6 +110,22 @@
</div>
</div>
<!-- Carbon Section -->
<div id="carbon-card" class="dashboard-card carbon-card" style="display: none;">
<h3>Carbon Footprint</h3>
<div class="carbon-grid">
<div class="carbon-item">
<div class="carbon-label">CO2 per Visit</div>
<div class="carbon-value" id="carbon-co2">-</div>
</div>
<div class="carbon-item">
<div class="carbon-label">Eco Rating</div>
<div class="carbon-value" id="carbon-rating">-</div>
</div>
</div>
<div id="carbon-badge" class="carbon-badge">-</div>
</div>
<!-- Vitals Section -->
<div class="dashboard-card vitals-card">
<h3>Web Vitals</h3>

View File

@@ -0,0 +1,82 @@
/**
* Carbon Calculator
* Based on the Sustainable Web Design model.
*/
// 1 byte = X kWh?
// The model: 0.81 kWh / GB for data transfer (2021/2022 estimate)
// Carbon intensity: ~442g/kWh (global average)
//
// Formula:
// Energy = (Data Transfer (GB) * 0.81) * 0.75 (system boundary)
// Carbon = Energy * 442g
const SWD_KWH_PER_GB = 0.81;
const CARBON_INTENSITY_GLOBAL = 442; // g/kWh
/**
* Checks if a domain is hosted on green infrastructure.
* @param {string} domain
* @returns {Promise<boolean>}
*/
async function checkGreenHosting(domain) {
try {
const res = await fetch(`https://api.thegreenwebfoundation.org/greencheck/${domain}`);
const data = await res.json();
return data.green || false;
} catch (e) {
console.warn('Green hosting check failed:', e.message);
return false;
}
}
/**
* Calculates the carbon footprint of a page load.
* @param {number} bytes - Total transfer size in bytes.
* @param {boolean} isGreen - Whether the host is green.
* @returns {object} - { co2: number (grams), rating: string }
*/
function calculateCarbon(bytes, isGreen) {
const gb = bytes / (1024 * 1024 * 1024);
let energy = gb * SWD_KWH_PER_GB * 0.75; // kWh
// Carbon intensity adjustment
// If green, we assume a lower intensity or offset (often considered 0 or low, but let's say 50g/kWh for production/network overhead)
// The standard generally keeps network intensity the same but reduced datacenter intensity.
// For simplicity/standard SWD: Green hosting reduces the data center portion.
// Let's use simple factor: if green, reduce total by 10-20%?
// Actually, SWD v3 splits it:
// Consumer device: 52%
// Network: 14%
// Data Center: 15%
// Hardware production: 19%
//
// Green hosting only affects Data Center (15%).
// So if green, we assume 0 emissions for that 15%, reducing total by 15%.
const intensity = isGreen ? CARBON_INTENSITY_GLOBAL * 0.85 : CARBON_INTENSITY_GLOBAL;
const co2 = energy * intensity;
// Rating (Eco-Index style)
// < 0.5g -> A+
// < 1g -> A
// < 2g -> B
// < 3g -> C
// > 5g -> F
let rating = 'F';
if(co2 < 0.5) rating = 'A+';
else if(co2 < 1.0) rating = 'A';
else if(co2 < 2.0) rating = 'B';
else if(co2 < 3.0) rating = 'C';
else if(co2 < 4.0) rating = 'D';
else if(co2 < 5.0) rating = 'E';
return {
co2: parseFloat(co2.toFixed(3)),
rating,
energy: parseFloat(energy.toFixed(5))
};
}
module.exports = { checkGreenHosting, calculateCarbon };

View File

@@ -0,0 +1,45 @@
const ffmpeg = require('fluent-ffmpeg');
const fs = require('fs');
/**
* Stitches two videos side-by-side.
* @param {string} video1 Path to first video
* @param {string} video2 Path to second video
* @param {string} outputPath Path to save output
* @returns {Promise<string>}
*/
function stitchVideos(video1, video2, outputPath) {
return new Promise((resolve, reject) => {
if (!fs.existsSync(video1) || !fs.existsSync(video2)) {
return reject(new Error('One or both input videos not found'));
}
ffmpeg()
.input(video1)
.input(video2)
.complexFilter([
{
filter: 'hstack',
options: { inputs: 2 },
outputs: 'v'
}
])
.map('v') // Map the video stream from filter
// We assume no audio for speed tests usually, or we ignore it
.outputOptions([
'-c:v libx264',
'-crf 23',
'-preset fast'
])
.save(outputPath)
.on('end', () => {
resolve(outputPath);
})
.on('error', (err) => {
console.error('FFmpeg error:', err);
reject(err);
});
});
}
module.exports = { stitchVideos };

View File

@@ -0,0 +1,67 @@
const ffmpeg = require('fluent-ffmpeg');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { v4: uuidv4 } = require('uuid');
/**
* Creates an MP4 video from an array of frame objects.
* @param {Array} frames - Array of { data: base64 } objects
* @param {number} outputFps - Frames per second
* @returns {Promise<string>} - Path to generated video file
*/
async function createVideoFromFrames(frames, outputFps = 10) {
if (!frames || frames.length === 0) throw new Error('No frames provided');
// Filter out invalid frames if any
const validFrames = frames.filter(f => f && f.data);
if (validFrames.length === 0) throw new Error('No valid frames found');
const tempDir = path.join(os.tmpdir(), 'frames-' + uuidv4());
fs.mkdirSync(tempDir, { recursive: true });
try {
// Save frames as images
for (let i = 0; i < validFrames.length; i++) {
const frame = validFrames[i];
// Remove header if present
const base64Data = frame.data.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, 'base64');
const filePath = path.join(tempDir, `frame-${i.toString().padStart(5, '0')}.jpg`);
fs.writeFileSync(filePath, buffer);
}
const outputPath = path.join(os.tmpdir(), `video-${uuidv4()}.mp4`);
return new Promise((resolve, reject) => {
ffmpeg()
.input(path.join(tempDir, 'frame-%05d.jpg'))
.inputFPS(outputFps)
.output(outputPath)
.outputOptions([
'-c:v libx264',
'-pix_fmt yuv420p',
'-crf 23',
'-preset fast',
'-movflags +faststart' // Optimize for web playback
])
.on('end', () => {
// Cleanup frames
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch(e) {}
resolve(outputPath);
})
.on('error', (err) => {
// Cleanup frames
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch(e) {}
console.error('FFmpeg video generation error:', err);
reject(err);
})
.run();
});
} catch (e) {
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch(err) {}
throw e;
}
}
module.exports = { createVideoFromFrames };

View File

@@ -0,0 +1,81 @@
const { exec } = require('child_process');
const dns = require('dns');
/**
* Executes a traceroute to the specified host.
* @param {string} host - The hostname or IP to trace.
* @returns {Promise<string>} - The raw stdout output of the traceroute command.
*/
function runTraceroute(host) {
return new Promise((resolve, reject) => {
// Basic validation to prevent command injection
if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
return reject(new Error('Invalid hostname format.'));
}
// Resolve domain to ensure it exists before running system command (optional safety)
dns.lookup(host, (err) => {
if (err) return reject(new Error(`Could not resolve host: ${host}`));
// Detect platform
const isWin = process.platform === 'win32';
const command = isWin ? `tracert -d -h 20 ${host}` : `traceroute -n -m 20 ${host}`;
exec(command, { timeout: 60000 }, (error, stdout, stderr) => {
if (error) {
// Traceroute might return error code if it doesn't reach dest, but stdout is valuable
if (stdout) resolve(stdout);
else reject(error || stderr);
} else {
resolve(stdout);
}
});
});
});
}
const geoip = require('geoip-lite');
/**
* Parses traceroute output and adds geolocation data.
* @param {string} output - Raw traceroute output.
* @returns {Array} - Array of hop objects with IP, RTT, and Location.
*/
function parseAndLocate(output) {
const lines = output.split('\n');
const hops = [];
// Regex to match lines like: " 1 192.168.1.1 2.5 ms"
// Linux: 1 _gateway (10.0.2.2) 0.224 ms 0.180 ms 0.120 ms
// Windows: 1 <1 ms <1 ms <1 ms 192.168.1.1
const ipRegex = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/;
lines.forEach(line => {
const match = line.match(ipRegex);
if (match) {
const ip = match[0];
const geo = geoip.lookup(ip);
hops.push({
raw: line.trim(),
ip: ip,
lat: geo ? geo.ll[0] : null,
lon: geo ? geo.ll[1] : null,
city: geo ? geo.city : null,
country: geo ? geo.country : null
});
}
});
return hops;
}
/**
* Executes traceroute and returns structured data.
*/
async function traceAndLocate(host) {
const rawOutput = await runTraceroute(host);
const hops = parseAndLocate(rawOutput);
return { raw: rawOutput, hops };
}
module.exports = { runTraceroute, traceAndLocate };

View File

@@ -174,9 +174,37 @@ async function _executeTest(url, options) {
},
userAgent: lhr.userAgent,
isMobile: isMobile,
filmstrip: captureFilmstrip ? (lhr.audits['screenshot-thumbnails']?.details?.items || []) : []
filmstrip: captureFilmstrip ? (lhr.audits['screenshot-thumbnails']?.details?.items || []) : [],
details: {
lcpElement: lhr.audits['largest-contentful-paint-element']?.details?.items?.[0] || null,
clsShifts: lhr.audits['layout-shifts']?.details?.items || [],
longTasks: lhr.audits['long-tasks']?.details?.items || []
}
};
// Calculate Carbon Footprint
try {
const { checkGreenHosting, calculateCarbon } = require('./analysis/carbon-calculator');
let transferSize = 0;
const networkItems = lhr.audits['network-requests']?.details?.items || [];
networkItems.forEach(item => {
transferSize += item.transferSize || 0;
});
const urlObj = new URL(lhr.finalUrl || url);
const isGreen = await checkGreenHosting(urlObj.hostname);
const carbonData = calculateCarbon(transferSize, isGreen);
summary.carbon = {
co2: carbonData.co2,
rating: carbonData.rating,
isGreen: isGreen,
transferSize: transferSize
};
} catch (carbonErr) {
console.warn('Carbon calculation failed:', carbonErr);
}
// Save JSON Summary
let jsonPath = path.join(reportDir, `${testId}.json`);
fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2));
@@ -263,7 +291,7 @@ async function _executeTest(url, options) {
options.userIp || null,
isMobile,
JSON.stringify(summary.scores),
JSON.stringify(summary.metrics),
JSON.stringify({ ...summary.metrics, carbon: summary.carbon, details: summary.details }),
JSON.stringify(filmstripData)
];

15
main.js
View File

@@ -124,6 +124,18 @@ function displayResults(data) {
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';
@@ -279,6 +291,9 @@ async function loadHistory() {
<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>

639
package-lock.json generated
View File

@@ -7,13 +7,15 @@
"": {
"name": "web-page-performance-test",
"version": "1.0.0",
"hasInstallScript": true,
"license": "MIT",
"license": "GPL-3.0",
"dependencies": {
"chrome-launcher": "^1.2.1",
"express": "^4.18.2",
"fluent-ffmpeg": "^2.1.3",
"geoip-lite": "^1.4.10",
"lighthouse": "^13.0.1",
"pg": "^8.16.3",
"puppeteer-core": "^21.0.0",
"uuid": "^13.0.0"
},
"devDependencies": {
@@ -641,33 +643,33 @@
}
},
"node_modules/@puppeteer/browsers": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.0.tgz",
"integrity": "sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.9.1.tgz",
"integrity": "sha512-PuvK6xZzGhKPvlx3fpfdM2kYY3P/hB1URtK8wA7XUJ6prn6pp22zvJHu48th0SGcHL9SutbPHrFuQgfXTFobWA==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.4.3",
"extract-zip": "^2.0.1",
"progress": "^2.0.3",
"proxy-agent": "^6.5.0",
"semver": "^7.7.3",
"tar-fs": "^3.1.1",
"yargs": "^17.7.2"
"debug": "4.3.4",
"extract-zip": "2.0.1",
"progress": "2.0.3",
"proxy-agent": "6.3.1",
"tar-fs": "3.0.4",
"unbzip2-stream": "1.4.3",
"yargs": "17.7.2"
},
"bin": {
"browsers": "lib/cjs/main-cli.js"
},
"engines": {
"node": ">=18"
"node": ">=16.3.0"
}
},
"node_modules/@puppeteer/browsers/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
@@ -679,9 +681,9 @@
}
},
"node_modules/@puppeteer/browsers/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/@sentry/core": {
@@ -968,6 +970,11 @@
"node": ">=4"
}
},
"node_modules/async": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
"integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
},
"node_modules/atomically": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.0.tgz",
@@ -1012,6 +1019,7 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -1098,6 +1106,26 @@
"bare-path": "^3.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/basic-ftp": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz",
@@ -1166,6 +1194,30 @@
"node": ">=8"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -1213,6 +1265,43 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -1257,13 +1346,13 @@
}
},
"node_modules/chromium-bidi": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-12.0.1.tgz",
"integrity": "sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==",
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.5.8.tgz",
"integrity": "sha512-blqh+1cEQbHBKmok3rVJkBlBxt9beKBgOsxbFgs7UJcoVbbeZ+K7+6liAsjgpc8l1Xd55cQUy14fXZdGSb4zIw==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
"mitt": "3.0.1",
"urlpattern-polyfill": "10.0.0"
},
"peerDependencies": {
"devtools-protocol": "*"
@@ -1311,7 +1400,6 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/configstore": {
@@ -1368,6 +1456,15 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/csp_evaluator": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/csp_evaluator/-/csp_evaluator-1.1.5.tgz",
@@ -1781,6 +1878,20 @@
"node": ">= 0.8"
}
},
"node_modules/fluent-ffmpeg": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
"integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"dependencies": {
"async": "^0.2.9",
"which": "^1.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1805,6 +1916,12 @@
"node": ">= 0.6"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1829,6 +1946,33 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/geoip-lite": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/geoip-lite/-/geoip-lite-1.4.10.tgz",
"integrity": "sha512-4N69uhpS3KFd97m00wiFEefwa+L+HT5xZbzPhwu+sDawStg6UN/dPwWtUfkQuZkGIY1Cj7wDVp80IsqNtGMi2w==",
"license": "Apache-2.0",
"dependencies": {
"async": "2.1 - 2.6.4",
"chalk": "4.1 - 4.1.2",
"iconv-lite": "0.4.13 - 0.6.3",
"ip-address": "5.8.9 - 5.9.4",
"lazy": "1.0.11",
"rimraf": "2.5.2 - 2.7.1",
"yauzl": "2.9.2 - 2.10.0"
},
"engines": {
"node": ">=10.3.0"
}
},
"node_modules/geoip-lite/node_modules/async": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
"integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.14"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -1927,6 +2071,27 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -1940,6 +2105,28 @@
"node": ">= 6"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -2105,6 +2292,26 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -2130,6 +2337,17 @@
"module-details-from-path": "^1.0.3"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2149,12 +2367,17 @@
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "5.9.4",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz",
"integrity": "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==",
"license": "MIT",
"dependencies": {
"jsbn": "1.1.0",
"lodash": "^4.17.15",
"sprintf-js": "1.1.2"
},
"engines": {
"node": ">= 12"
"node": ">= 0.10"
}
},
"node_modules/ipaddr.js": {
@@ -2263,6 +2486,12 @@
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
@@ -2278,6 +2507,21 @@
"node": ">=12"
}
},
"node_modules/jsbn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
"license": "MIT"
},
"node_modules/lazy": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz",
"integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==",
"license": "MIT",
"engines": {
"node": ">=0.2.0"
}
},
"node_modules/legacy-javascript": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.1.tgz",
@@ -2364,6 +2608,148 @@
"integrity": "sha512-d8IsOpE83kbANgnM+Tp8+x6HcMpX9o2ITBiUERssgzAIFdZCQzs/f4k6D0DLQTE59enml9mbAOU52Wu35exWtg==",
"license": "Apache-2.0"
},
"node_modules/lighthouse/node_modules/@puppeteer/browsers": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.0.tgz",
"integrity": "sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.4.3",
"extract-zip": "^2.0.1",
"progress": "^2.0.3",
"proxy-agent": "^6.5.0",
"semver": "^7.7.3",
"tar-fs": "^3.1.1",
"yargs": "^17.7.2"
},
"bin": {
"browsers": "lib/cjs/main-cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/lighthouse/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/lighthouse/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/lighthouse/node_modules/proxy-agent": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
"integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"http-proxy-agent": "^7.0.1",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^7.14.1",
"pac-proxy-agent": "^7.1.0",
"proxy-from-env": "^1.1.0",
"socks-proxy-agent": "^8.0.5"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/lighthouse/node_modules/puppeteer-core": {
"version": "24.34.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.34.0.tgz",
"integrity": "sha512-24evawO+mUGW4mvS2a2ivwLdX3gk8zRLZr9HP+7+VT2vBQnm0oh9jJEZmUE3ePJhRkYlZ93i7OMpdcoi2qNCLg==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.11.0",
"chromium-bidi": "12.0.1",
"debug": "^4.4.3",
"devtools-protocol": "0.0.1534754",
"typed-query-selector": "^2.12.0",
"webdriver-bidi-protocol": "0.3.10",
"ws": "^8.18.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/lighthouse/node_modules/puppeteer-core/node_modules/chromium-bidi": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-12.0.1.tgz",
"integrity": "sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/lighthouse/node_modules/puppeteer-core/node_modules/devtools-protocol": {
"version": "0.0.1534754",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz",
"integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==",
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/lighthouse/node_modules/puppeteer-core/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/lighthouse/node_modules/tar-fs": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
"integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.22",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
@@ -2481,6 +2867,12 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/module-details-from-path": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
@@ -2511,6 +2903,26 @@
"node": ">= 0.4.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@@ -2713,6 +3125,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2896,19 +3317,19 @@
}
},
"node_modules/proxy-agent": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
"integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.1.tgz",
"integrity": "sha512-Rb5RVBy1iyqOtNl15Cw/llpeLH8bsb37gM1FUfKQ+Wck6xHlbAhWGUFiTRHtkjqGTA5pSHz6+0hrPW/oECihPQ==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"agent-base": "^7.0.2",
"debug": "^4.3.4",
"http-proxy-agent": "^7.0.1",
"https-proxy-agent": "^7.0.6",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"lru-cache": "^7.14.1",
"pac-proxy-agent": "^7.1.0",
"pac-proxy-agent": "^7.0.1",
"proxy-from-env": "^1.1.0",
"socks-proxy-agent": "^8.0.5"
"socks-proxy-agent": "^8.0.2"
},
"engines": {
"node": ">= 14"
@@ -2961,30 +3382,29 @@
}
},
"node_modules/puppeteer-core": {
"version": "24.34.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.34.0.tgz",
"integrity": "sha512-24evawO+mUGW4mvS2a2ivwLdX3gk8zRLZr9HP+7+VT2vBQnm0oh9jJEZmUE3ePJhRkYlZ93i7OMpdcoi2qNCLg==",
"version": "21.11.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-21.11.0.tgz",
"integrity": "sha512-ArbnyA3U5SGHokEvkfWjW+O8hOxV1RSJxOgriX/3A4xZRqixt9ZFHD0yPgZQF05Qj0oAqi8H/7stDorjoHY90Q==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.11.0",
"chromium-bidi": "12.0.1",
"debug": "^4.4.3",
"devtools-protocol": "0.0.1534754",
"typed-query-selector": "^2.12.0",
"webdriver-bidi-protocol": "0.3.10",
"ws": "^8.18.3"
"@puppeteer/browsers": "1.9.1",
"chromium-bidi": "0.5.8",
"cross-fetch": "4.0.0",
"debug": "4.3.4",
"devtools-protocol": "0.0.1232444",
"ws": "8.16.0"
},
"engines": {
"node": ">=18"
"node": ">=16.13.2"
}
},
"node_modules/puppeteer-core/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
@@ -2996,21 +3416,21 @@
}
},
"node_modules/puppeteer-core/node_modules/devtools-protocol": {
"version": "0.0.1534754",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz",
"integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==",
"version": "0.0.1232444",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1232444.tgz",
"integrity": "sha512-pM27vqEfxSxRkTMnF+XCmxSEb6duO5R+t8A9DEEJgy4Wz2RVanje2mmj99B6A3zv2r/qGfYlOvYznUhuokizmg==",
"license": "BSD-3-Clause"
},
"node_modules/puppeteer-core/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/puppeteer-core/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -3146,6 +3566,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/robots-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-3.0.1.tgz",
@@ -3396,6 +3829,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socks/node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -3429,6 +3871,12 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
"license": "BSD-3-Clause"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -3516,17 +3964,14 @@
}
},
"node_modules/tar-fs": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
"integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz",
"integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==",
"license": "MIT",
"dependencies": {
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
}
},
"node_modules/tar-stream": {
@@ -3555,6 +4000,12 @@
"integrity": "sha512-h0JYX+dO2Zr3abCQpS6/uFjujaOjA1DyDzGQ41+oFn9VW/ARiq9g5ln7qEP9+BTzDpOMyIfsfj4OvfgXAsMUSA==",
"license": "MIT"
},
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"license": "MIT"
},
"node_modules/tldts-core": {
"version": "7.0.19",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz",
@@ -3602,6 +4053,12 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3639,6 +4096,16 @@
"integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==",
"license": "MIT"
},
"node_modules/unbzip2-stream": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
"integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==",
"license": "MIT",
"dependencies": {
"buffer": "^5.2.1",
"through": "^2.3.8"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -3661,6 +4128,12 @@
"node": ">= 0.8"
}
},
"node_modules/urlpattern-polyfill": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz",
"integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -3698,12 +4171,40 @@
"integrity": "sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/when-exit": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz",
"integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==",
"license": "MIT"
},
"node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"which": "bin/which"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -17,6 +17,8 @@
"dependencies": {
"chrome-launcher": "^1.2.1",
"express": "^4.18.2",
"fluent-ffmpeg": "^2.1.3",
"geoip-lite": "^1.4.10",
"lighthouse": "^13.0.1",
"pg": "^8.16.3",
"puppeteer-core": "^21.0.0",

View File

@@ -3,6 +3,7 @@ const path = require("path");
const fs = require("fs");
const { exec } = require("child_process");
const runner = require("./lib/runner");
const { traceAndLocate } = require("./lib/diagnostics/traceroute");
const app = express();
const PORT = process.env.PORT || 3000;
@@ -99,17 +100,72 @@ app.get("*", (req, res) => {
});
// API Endpoint: Traceroute
app.get("/api/traceroute", (req, res) => {
const { host } = req.query;
// API Endpoint: Traceroute
app.post("/api/traceroute", async (req, res) => {
const { host } = req.body;
if (!host) return res.status(400).json({ error: "Host required" });
// Sanitize host to prevent injection (basic)
if (/[^a-zA-Z0-9.-]/.test(host)) return res.status(400).json({ error: "Invalid host" });
try {
const { raw, hops } = await traceAndLocate(host);
res.json({ output: raw, hops });
} catch (error) {
console.error("Traceroute error:", error);
res.status(500).json({ error: "Traceroute failed", details: error.message });
}
});
const cmd = process.platform === 'win32' ? `tracert -h 10 ${host}` : `traceroute -m 10 ${host}`;
exec(cmd, (error, stdout, stderr) => {
res.json({ output: stdout || stderr });
});
// API Endpoint: Compare Videos
app.get("/api/compare", async (req, res) => {
const { test1, test2 } = req.query;
if (!test1 || !test2) return res.status(400).send("Missing test IDs");
const reportDir = path.join(__dirname, 'reports');
// Helper to get frames
const getFrames = (id) => {
const p = path.join(reportDir, `${id}.json`);
if (!fs.existsSync(p)) return null;
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
return data.filmstrip;
};
try {
const frames1 = getFrames(test1);
const frames2 = getFrames(test2);
if (!frames1 || !frames2) return res.status(404).send("Test results not found or missing filmstrip");
// Dynamically import generator and stitcher
const { createVideoFromFrames } = require('./lib/comparison/video-generator');
const { stitchVideos } = require('./lib/comparison/stitcher');
// Generate individual videos
// We assume 10 FPS as per capture logic
const [video1, video2] = await Promise.all([
createVideoFromFrames(frames1, 10),
createVideoFromFrames(frames2, 10)
]);
// Stitch them
const outputFilename = `comparison-${test1}-${test2}.mp4`;
const outputPath = path.join(reportDir, outputFilename);
await stitchVideos(video1, video2, outputPath);
// Cleanup temp individual videos
try { fs.unlinkSync(video1); fs.unlinkSync(video2); } catch(e) {}
// Stream output
res.setHeader('Content-Type', 'video/mp4');
const stream = fs.createReadStream(outputPath);
stream.pipe(res);
// Optional: Cleanup comparison file after streaming?
// Or keep it cached. For now, keep it.
} catch (err) {
console.error("Comparison error:", err);
res.status(500).send("Comparison failed");
}
});
// API Endpoint: Bulk Test

View File

@@ -120,98 +120,151 @@ body::before {
GTmetrix Dashboard
========================================= */
.gtmetrix-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.dashboard-card {
background: var(--color-bg-tertiary); /* Darker internal card */
padding: 1.5rem;
border-radius: 12px;
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 180px;
background: var(--color-bg-tertiary); /* Darker internal card */
padding: 1.5rem;
border-radius: 12px;
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 180px;
}
.grade-card h3 { margin-bottom: 1rem; color: var(--color-text-secondary); font-size: 0.9rem; text-transform: uppercase; letter-spacing: 1px; }
.grade-card h3 {
margin-bottom: 1rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.grade-circle {
width: 100px;
height: 100px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3.5rem;
font-weight: 800;
color: #fff;
background: #444;
transition: all 0.5s ease;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
width: 100px;
height: 100px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3.5rem;
font-weight: 800;
color: #fff;
background: #444;
transition: all 0.5s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.grade-a { background: #0cce6b; box-shadow: 0 0 20px rgba(12, 206, 107, 0.4); }
.grade-b { background: #0ba759; }
.grade-c { background: #ffaa00; }
.grade-d { background: #ff4e00; }
.grade-f { background: #ff0000; }
.grade-a {
background: #0cce6b;
box-shadow: 0 0 20px rgba(12, 206, 107, 0.4);
}
.grade-b {
background: #0ba759;
}
.grade-c {
background: #ffaa00;
}
.grade-d {
background: #ff4e00;
}
.grade-f {
background: #ff0000;
}
.scores-card {
flex-direction: row;
gap: 2rem;
flex-direction: row;
gap: 2rem;
}
.score-item { text-align: center; }
.score-label { font-size: 0.9rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.score-value { font-size: 2.5rem; font-weight: 700; color: var(--color-text-primary); }
.score-divider { width: 1px; height: 60px; background: var(--color-border); }
.score-item {
text-align: center;
}
.score-label {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
}
.score-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--color-text-primary);
}
.score-divider {
width: 1px;
height: 60px;
background: var(--color-border);
}
.vitals-card { align-items: stretch; }
.vitals-card h3 { text-align: center; margin-bottom: 1rem; font-size: 0.9rem; color: var(--color-text-secondary); text-transform: uppercase; }
.vitals-card {
align-items: stretch;
}
.vitals-card h3 {
text-align: center;
margin-bottom: 1rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
text-transform: uppercase;
}
.vitals-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
text-align: center;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
text-align: center;
}
.vital-label {
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.vital-value {
font-size: 1.2rem;
font-weight: 500;
color: var(--color-accent);
}
.vital-label { font-size: 0.8rem; font-weight: 600; margin-bottom: 0.25rem; }
.vital-value { font-size: 1.2rem; font-weight: 500; color: var(--color-accent); }
/* Filmstrip */
.filmstrip-container {
display: flex;
overflow-x: auto;
gap: 0.5rem;
padding: 1rem 0;
scrollbar-width: thin;
display: flex;
overflow-x: auto;
gap: 0.5rem;
padding: 1rem 0;
scrollbar-width: thin;
}
.filmstrip-frame {
flex: 0 0 auto;
width: 100px;
text-align: center;
flex: 0 0 auto;
width: 100px;
text-align: center;
}
.filmstrip-frame img {
width: 100%;
height: auto;
border: 1px solid var(--color-border);
margin-bottom: 0.25rem;
width: 100%;
height: auto;
border: 1px solid var(--color-border);
margin-bottom: 0.25rem;
}
.frame-time {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.frame-time { font-size: 0.75rem; color: var(--color-text-secondary); }
/* Checkbox */
.checkbox-container {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.checkbox-container input {
width: 16px;
height: 16px;
}
.checkbox-container input { width: 16px; height: 16px; }
/* ===================================
LAYOUT STRUCTURE
@@ -323,94 +376,110 @@ body::before {
/* Footer */
.footer {
text-align: center;
margin-top: var(--spacing-2xl);
padding: var(--spacing-xl) 0;
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 0.875rem;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
align-items: center;
text-align: center;
margin-top: var(--spacing-2xl);
padding: var(--spacing-xl) 0;
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 0.875rem;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
align-items: center;
}
/* GitHub Link */
.github-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-lg);
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: var(--radius-lg);
color: var(--color-accent-primary);
text-decoration: none;
font-weight: 500;
font-size: 0.875rem;
transition: all var(--transition-base);
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-lg);
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.1) 0%,
rgba(139, 92, 246, 0.1) 100%
);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: var(--radius-lg);
color: var(--color-accent-primary);
text-decoration: none;
font-weight: 500;
font-size: 0.875rem;
transition: all var(--transition-base);
backdrop-filter: blur(10px);
}
.github-link:hover {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%);
border-color: rgba(99, 102, 241, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
color: var(--color-accent-primary);
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.2) 0%,
rgba(139, 92, 246, 0.2) 100%
);
border-color: rgba(99, 102, 241, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
color: var(--color-accent-primary);
}
.github-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
/* Version Badge */
.version-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-lg);
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%);
border: 1.5px solid rgba(16, 185, 129, 0.3);
border-radius: 50px;
font-family: var(--font-mono);
font-size: 0.8125rem;
margin: 0 auto;
transition: all var(--transition-base);
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-lg);
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.1) 0%,
rgba(5, 150, 105, 0.1) 100%
);
border: 1.5px solid rgba(16, 185, 129, 0.3);
border-radius: 50px;
font-family: var(--font-mono);
font-size: 0.8125rem;
margin: 0 auto;
transition: all var(--transition-base);
backdrop-filter: blur(10px);
}
.version-badge:hover {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%);
border-color: rgba(16, 185, 129, 0.5);
transform: translateY(-1px) scale(1.02);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.15) 0%,
rgba(5, 150, 105, 0.15) 100%
);
border-color: rgba(16, 185, 129, 0.5);
transform: translateY(-1px) scale(1.02);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
}
.badge-icon {
width: 1rem;
height: 1rem;
color: var(--color-accent-success);
flex-shrink: 0;
width: 1rem;
height: 1rem;
color: var(--color-accent-success);
flex-shrink: 0;
}
.commit-id {
color: var(--color-accent-success);
font-weight: 700;
letter-spacing: 0.5px;
text-shadow: 0 0 10px rgba(16, 185, 129, 0.3);
color: var(--color-accent-success);
font-weight: 700;
letter-spacing: 0.5px;
text-shadow: 0 0 10px rgba(16, 185, 129, 0.3);
}
.version-separator {
color: rgba(16, 185, 129, 0.5);
font-weight: 300;
color: rgba(16, 185, 129, 0.5);
font-weight: 300;
}
.commit-age {
color: var(--color-text-secondary);
font-weight: 400;
color: var(--color-text-secondary);
font-weight: 400;
}
/* ===================================
@@ -594,7 +663,9 @@ body::before {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
/* Results Section */
@@ -637,9 +708,18 @@ body::before {
font-size: 0.875rem;
}
.score-good { color: var(--color-accent-success); -webkit-text-fill-color: var(--color-accent-success); }
.score-average { color: var(--color-accent-warning); -webkit-text-fill-color: var(--color-accent-warning); }
.score-poor { color: var(--color-accent-danger); -webkit-text-fill-color: var(--color-accent-danger); }
.score-good {
color: var(--color-accent-success);
-webkit-text-fill-color: var(--color-accent-success);
}
.score-average {
color: var(--color-accent-warning);
-webkit-text-fill-color: var(--color-accent-warning);
}
.score-poor {
color: var(--color-accent-danger);
-webkit-text-fill-color: var(--color-accent-danger);
}
/* History List */
.history-list {
@@ -700,152 +780,190 @@ body::before {
VIDEO PLAYER MODAL
=================================== */
.modal {
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(5px);
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(5px);
}
.modal-content {
position: relative;
background: var(--color-bg-secondary);
margin: 3% auto;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
width: 95%;
max-width: 1400px;
box-shadow: var(--shadow-lg);
animation: slideIn 0.3s ease-out;
position: relative;
background: var(--color-bg-secondary);
margin: 3% auto;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
width: 95%;
max-width: 1400px;
box-shadow: var(--shadow-lg);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
color: var(--color-text-primary);
font-size: 1.5rem;
margin: 0;
color: var(--color-text-primary);
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-fast);
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
font-size: 2rem;
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-fast);
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: var(--color-accent-danger);
color: var(--color-accent-danger);
}
.video-modal-content {
max-width: 1200px;
background: var(--color-bg-secondary);
max-width: 1200px;
background: var(--color-bg-secondary);
}
.video-player-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: var(--spacing-lg);
}
.video-frame {
width: 100%;
aspect-ratio: 16/9;
background: #000;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
overflow: hidden;
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #000;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
overflow: hidden;
position: relative;
}
.video-frame img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* Carbon Card */
.carbon-card {
border-color: rgba(16, 185, 129, 0.2);
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.05) 0%,
rgba(5, 150, 105, 0.05) 100%
);
}
.carbon-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
width: 100%;
}
.carbon-item {
text-align: center;
}
.carbon-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
margin-bottom: 0.25rem;
}
.carbon-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text-primary);
}
.carbon-badge {
margin-top: 1rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
background: rgba(16, 185, 129, 0.2);
color: var(--color-accent-success);
font-weight: 600;
}
.video-controls {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: var(--spacing-sm);
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: var(--spacing-sm);
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
}
#video-play-btn {
background: var(--color-accent-primary);
color: white;
border: none;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
transition: all var(--transition-fast);
white-space: nowrap;
background: var(--color-accent-primary);
color: white;
border: none;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
transition: all var(--transition-fast);
white-space: nowrap;
}
#video-play-btn:hover {
background: var(--color-accent-secondary);
transform: translateY(-1px);
background: var(--color-accent-secondary);
transform: translateY(-1px);
}
.video-progress-bar {
flex: 1;
height: 8px;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
overflow: hidden;
cursor: pointer;
flex: 1;
height: 8px;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
overflow: hidden;
cursor: pointer;
}
.video-progress-fill {
height: 100%;
width: 0%;
background: var(--color-accent-primary);
transition: width 0.1s linear;
height: 100%;
width: 0%;
background: var(--color-accent-primary);
transition: width 0.1s linear;
}
#video-time {
font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--color-text-primary);
min-width: 4rem;
text-align: right;
font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--color-text-primary);
min-width: 4rem;
text-align: right;
}

212
traceroute.html Normal file
View File

@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Network Traceroute - Web Page Performance Test</title>
<link rel="icon" type="image/png" href="Logo.png" />
<link rel="stylesheet" href="styles.css?v=2.2" />
<!-- Leaflet CSS for Map -->
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<style>
#map {
height: 400px;
width: 100%;
border-radius: 8px;
margin-top: 1rem;
}
.terminal-output {
background: #1e1e1e;
color: #0f0;
padding: 1rem;
border-radius: 8px;
font-family: monospace;
white-space: pre-wrap;
margin-top: 1rem;
max-height: 300px;
overflow-y: auto;
}
.hop-list {
list-style: none;
padding: 0;
}
.hop-item {
padding: 0.5rem;
border-bottom: 1px solid #333;
display: flex;
justify-content: space-between;
}
.hop-geo {
color: #888;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="container">
<!-- Header (Shared) -->
<header class="header">
<div class="header-content">
<h1 class="title">
<img src="Logo.png" alt="BCT Logo" class="title-icon" />
Network Path Analysis
</h1>
<p class="subtitle">Traceroute & Geolocation</p>
<!-- Navigation -->
<nav style="margin-left: auto; display: flex; gap: 1rem">
<a
href="/"
class="button"
style="color: white; text-decoration: none"
>⬅ Back to Speed Test</a
>
</nav>
</div>
</header>
<main class="main-content">
<div class="panel">
<div class="panel-header">
<div class="panel-icon">🌍</div>
<h2 class="panel-title">Trace Remote Host</h2>
</div>
<div class="launcher-form">
<div class="form-group">
<label class="form-label" for="trace-host">Hostname or IP</label>
<input
type="text"
id="trace-host"
class="form-input"
placeholder="google.com"
required
/>
</div>
<button
id="trace-btn"
class="btn-primary"
onclick="runTraceroute()"
>
<span>Run Traceroute</span>
<div
id="loading-spinner"
class="loading-spinner"
style="display: none"
></div>
</button>
<p
id="error-msg"
style="color: var(--color-accent-danger); display: none"
></p>
</div>
</div>
<div id="results-area" class="results-container" style="display: none">
<h3>Path Visualization</h3>
<div id="map"></div>
<h3>Raw Output</h3>
<div id="terminal-output" class="terminal-output"></div>
</div>
</main>
<footer class="footer">
<p>&copy; 2025 Beyond Cloud Technology.</p>
</footer>
</div>
<!-- Leaflet JS -->
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
></script>
<script>
let map = null;
async function runTraceroute() {
const host = document.getElementById("trace-host").value;
const btn = document.getElementById("trace-btn");
const spinner = document.getElementById("loading-spinner");
const errorMsg = document.getElementById("error-msg");
const resultsArea = document.getElementById("results-area");
const terminal = document.getElementById("terminal-output");
if (!host) return;
// UI Reset
btn.disabled = true;
spinner.style.display = "block";
errorMsg.style.display = "none";
resultsArea.style.display = "none";
terminal.textContent =
"Tracing... please wait (this can take up to 60s)...";
try {
const response = await fetch("/api/traceroute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ host }),
});
const data = await response.json();
if (data.error) throw new Error(data.details || data.error);
// Show Results
resultsArea.style.display = "block";
terminal.textContent = data.output;
initMap(data.hops);
} catch (err) {
errorMsg.textContent = err.message;
errorMsg.style.display = "block";
terminal.textContent = "Error occurred.";
} finally {
btn.disabled = false;
spinner.style.display = "none";
}
}
function initMap(hops) {
if (map) {
map.remove(); // Reset map
}
// Initialize map (center on 0,0 default, or first hop)
map = L.map("map").setView([20, 0], 2);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18,
attribution: "© OpenStreetMap",
}).addTo(map);
const latlngs = [];
hops.forEach((hop) => {
if (hop.lat && hop.lon) {
const coord = [hop.lat, hop.lon];
latlngs.push(coord);
L.marker(coord)
.addTo(map)
.bindPopup(
`<b>${hop.ip}</b><br>${hop.city || ""}, ${hop.country || ""}`
);
}
});
if (latlngs.length > 0) {
const polyline = L.polyline(latlngs, { color: "red" }).addTo(map);
map.fitBounds(polyline.getBounds());
}
}
</script>
</body>
</html>

185
vitals.html Normal file
View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Advanced Web Vitals - Web Page Performance Test</title>
<link rel="icon" type="image/png" href="Logo.png">
<link rel="stylesheet" href="styles.css?v=2.2">
<style>
.vitals-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.metric-block {
background: var(--color-bg-tertiary);
padding: 2rem;
border-radius: 12px;
border: 1px solid var(--color-border);
margin-bottom: 2rem;
}
.metric-title {
font-size: 1.5rem;
margin-bottom: 1rem;
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.5rem;
}
.lcp-element-box {
background: rgba(0,0,0,0.3);
padding: 1rem;
border-radius: 6px;
font-family: monospace;
overflow-x: auto;
color: #a5d6ff;
}
.cls-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.cls-table th, .cls-table td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.cls-table th { color: var(--color-text-secondary); }
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1 class="title">Advanced Vitals Analysis</h1>
<nav>
<a href="/" class="btn-secondary">⬅ Dashboard</a>
</nav>
</header>
<div class="vitals-container" id="content-area">
<div id="loading" style="text-align: center;">Loading Vitals Data...</div>
<!-- LCP Section -->
<div id="lcp-section" class="metric-block" style="display:none;">
<h2 class="metric-title">Largest Contentful Paint (LCP)</h2>
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
<div style="flex: 1; min-width: 300px;">
<h3>LCP Element</h3>
<div id="lcp-element-code" class="lcp-element-box"></div>
</div>
<div style="flex: 1; min-width: 300px;">
<h3>Details</h3>
<p><strong>Load Time:</strong> <span id="lcp-time" style="color: var(--color-accent); font-weight: bold;"></span></p>
<p><strong>Phase Breakdown:</strong></p>
<ul id="lcp-phases" style="list-style: none; padding-left: 0.5rem; line-height: 1.8;"></ul>
</div>
</div>
</div>
<!-- CLS Section -->
<div id="cls-section" class="metric-block" style="display:none;">
<h2 class="metric-title">Cumulative Layout Shift (CLS)</h2>
<h3>Shift Events</h3>
<table class="cls-table">
<thead>
<tr>
<th>Time</th>
<th>Score</th>
<th>Moved Elements</th>
</tr>
</thead>
<tbody id="cls-tbody"></tbody>
</table>
</div>
<!-- Long Tasks Section -->
<div id="tbt-section" class="metric-block" style="display:none;">
<h2 class="metric-title">Total Blocking Time (TBT) & Long Tasks</h2>
<div id="long-tasks-list"></div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const params = new URLSearchParams(window.location.search);
const id = params.get('id');
if (!id) {
document.getElementById('loading').textContent = 'No Test ID provided.';
return;
}
try {
const response = await fetch(`/reports/${id}.json`);
if (!response.ok) throw new Error('Report not found');
const data = await response.json();
renderVitals(data);
} catch (e) {
document.getElementById('loading').textContent = 'Error: ' + e.message;
}
});
function renderVitals(data) {
document.getElementById('loading').style.display = 'none';
const details = data.metrics.details || {}; // This comes from my runner.js update
// LCP
const lcpSec = document.getElementById('lcp-section');
if (details.lcpElement) {
lcpSec.style.display = 'block';
document.getElementById('lcp-element-code').textContent = details.lcpElement.node?.snippet || 'N/A';
document.getElementById('lcp-time').textContent = data.metrics.lcp.toFixed(0) + 'ms';
// phases if extracted, otherwise extract from audits in future
}
// CLS
const clsSec = document.getElementById('cls-section');
if (details.clsShifts && details.clsShifts.length > 0) {
clsSec.style.display = 'block';
const tbody = document.getElementById('cls-tbody');
details.clsShifts.forEach(shift => {
const row = document.createElement('tr');
// shift.node might be undefined if it's not element based or structured differently
// Lighthouse CLS structure varies. usually items has `score`, `startTime`, `rects`?
// Actually, items have `score` and `affectedWindows`.
// Wait, `layout-shifts` or `cumulative-layout-shift` details structure:
// It lists the shifts.
// Let's assume standard properties or robust fallback.
const time = shift.startTime ? (shift.startTime).toFixed(0) + 'ms' : '-';
const score = shift.score ? shift.score.toFixed(4) : '0';
const nodes = (shift.impactedNodes || []).map(n => n.node?.snippet).join('<br>') || 'Unknown';
row.innerHTML = `<td>${time}</td><td>${score}</td><td>${nodes}</td>`;
tbody.appendChild(row);
});
}
// TBT / Long Tasks
const tbtSec = document.getElementById('tbt-section');
if (details.longTasks && details.longTasks.length > 0) {
tbtSec.style.display = 'block';
const list = document.getElementById('long-tasks-list');
// Sort by duration desc
const tasks = details.longTasks.sort((a,b) => b.duration - a.duration).slice(0, 10);
tasks.forEach(task => {
const div = document.createElement('div');
div.style.marginBottom = '0.5rem';
div.style.padding = '0.5rem';
div.style.background = 'rgba(255,100,100,0.1)';
div.style.borderLeft = '3px solid red';
div.innerHTML = `<strong>${task.duration.toFixed(0)}ms</strong> at ${task.startTime.toFixed(0)}ms - ${task.url || 'Script Evaluation'}`;
list.appendChild(div);
});
}
if (!details.lcpElement && (!details.clsShifts || details.clsShifts.length === 0) && (!details.longTasks || details.longTasks.length === 0)) {
document.getElementById('content-area').innerHTML += '<p>No advanced details available for this test. (Test might be old or failed to extract details)</p>';
}
}
</script>
</body>
</html>