feat: initial Speedboard implementation
sitespeed.io web UI with Express/Pug/SQLite — port 3132. Includes job queue, SSE live log, full metrics dashboard, site history, CO2/axe/CWV sections, and Docker support. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
175
parser.js
Normal file
175
parser.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import { readdir, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Walk the outputFolder looking for the pageSummary JSON files
|
||||
* produced by sitespeed.io. The structure is:
|
||||
* outputFolder/pages/<hostname>/<urlpath>/<plugin>.pageSummary.json
|
||||
*/
|
||||
async function findPageSummaries(outputFolder) {
|
||||
const pagesDir = join(outputFolder, 'pages');
|
||||
const summaries = {};
|
||||
|
||||
async function walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
await walk(full);
|
||||
} else if (e.name.endsWith('.pageSummary.json')) {
|
||||
const plugin = e.name.replace('.pageSummary.json', '');
|
||||
if (!summaries[plugin]) summaries[plugin] = [];
|
||||
summaries[plugin].push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(pagesDir);
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async function readJson(filePath) {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function safe(obj, ...path) {
|
||||
let cur = obj;
|
||||
for (const key of path) {
|
||||
if (cur == null || typeof cur !== 'object') return null;
|
||||
cur = cur[key];
|
||||
}
|
||||
return cur ?? null;
|
||||
}
|
||||
|
||||
function median(obj) {
|
||||
return safe(obj, 'median') ?? safe(obj, 'mean') ?? null;
|
||||
}
|
||||
|
||||
export async function parseResults(outputFolder, _url) {
|
||||
const summaries = await findPageSummaries(outputFolder);
|
||||
const metrics = {};
|
||||
|
||||
// ─── browsertime.pageSummary ───────────────────────────────────────────────
|
||||
const btFiles = summaries['browsertime'] || [];
|
||||
if (btFiles.length > 0) {
|
||||
const bt = await readJson(btFiles[0]);
|
||||
const stats = safe(bt, 'statistics');
|
||||
const timings = safe(stats, 'timings');
|
||||
const pageTimings = safe(timings, 'pageTimings');
|
||||
const userTimings = safe(timings, 'userTimings');
|
||||
const visualMetrics = safe(stats, 'visualMetrics');
|
||||
const cpu = safe(stats, 'cpu');
|
||||
const axe = safe(bt, 'accessibility', 'summary');
|
||||
|
||||
// Core Web Vitals / timings
|
||||
if (timings) {
|
||||
metrics.ttfb = median(safe(timings, 'timeToFirstByte'));
|
||||
metrics.fcp = median(safe(timings, 'firstContentfulPaint'));
|
||||
metrics.lcp = median(safe(timings, 'largestContentfulPaint'));
|
||||
metrics.cls = median(safe(timings, 'cumulativeLayoutShift'));
|
||||
metrics.tbt = median(safe(timings, 'totalBlockingTime'));
|
||||
metrics.max_potential_fid = median(safe(timings, 'maxPotentialFID'));
|
||||
}
|
||||
|
||||
if (pageTimings) {
|
||||
metrics.page_load_time = median(safe(pageTimings, 'pageLoadTime'));
|
||||
metrics.fully_loaded = median(safe(pageTimings, 'fullyLoaded'));
|
||||
metrics.dom_content_loaded = median(safe(pageTimings, 'domContentLoadedEventEnd'));
|
||||
metrics.dom_interactive = median(safe(pageTimings, 'domInteractive'));
|
||||
metrics.front_end_time = median(safe(pageTimings, 'frontEndTime'));
|
||||
metrics.back_end_time = median(safe(pageTimings, 'backEndTime'));
|
||||
metrics.time_to_first_byte = median(safe(pageTimings, 'timeToFirstByte'))
|
||||
?? metrics.ttfb;
|
||||
}
|
||||
|
||||
if (visualMetrics) {
|
||||
metrics.speed_index = median(safe(visualMetrics, 'SpeedIndex'));
|
||||
metrics.first_visual_change = median(safe(visualMetrics, 'FirstVisualChange'));
|
||||
metrics.last_visual_change = median(safe(visualMetrics, 'LastVisualChange'));
|
||||
metrics.visual_complete_85 = median(safe(visualMetrics, 'VisualComplete85'));
|
||||
metrics.perceptual_speed_index = median(safe(visualMetrics, 'PerceptualSpeedIndex'));
|
||||
}
|
||||
|
||||
// CPU / Long Tasks
|
||||
if (cpu) {
|
||||
metrics.long_tasks_count = median(safe(cpu, 'longTasks', 'tasks'));
|
||||
metrics.long_tasks_duration = median(safe(cpu, 'longTasks', 'totalDuration'));
|
||||
}
|
||||
|
||||
// Axe accessibility
|
||||
if (axe) {
|
||||
metrics.axe_critical = safe(axe, 'critical') ?? 0;
|
||||
metrics.axe_serious = safe(axe, 'serious') ?? 0;
|
||||
metrics.axe_moderate = safe(axe, 'moderate') ?? 0;
|
||||
metrics.axe_minor = safe(axe, 'minor') ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── coach.pageSummary ─────────────────────────────────────────────────────
|
||||
const coachFiles = summaries['coach'] || [];
|
||||
if (coachFiles.length > 0) {
|
||||
const coach = await readJson(coachFiles[0]);
|
||||
const advice = safe(coach, 'advice');
|
||||
if (advice) {
|
||||
metrics.score_overall = safe(advice, 'score') ?? safe(advice, 'overall', 'score');
|
||||
metrics.score_performance = safe(advice, 'performance', 'score');
|
||||
metrics.score_accessibility = safe(advice, 'accessibility', 'score');
|
||||
metrics.score_bestpractice = safe(advice, 'bestpractice', 'score');
|
||||
metrics.score_privacy = safe(advice, 'privacy', 'score');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── pagexray.pageSummary ──────────────────────────────────────────────────
|
||||
const xrayFiles = summaries['pagexray'] || [];
|
||||
if (xrayFiles.length > 0) {
|
||||
const xray = await readJson(xrayFiles[0]);
|
||||
// pagexray has multiple runs — use the first or median-like object
|
||||
const page = Array.isArray(xray) ? xray[0] : xray;
|
||||
const ct = safe(page, 'contentTypes');
|
||||
|
||||
if (ct) {
|
||||
metrics.transfer_total = safe(page, 'transferSize');
|
||||
metrics.requests_total = safe(page, 'requests');
|
||||
metrics.transfer_html = safe(ct, 'html', 'transferSize');
|
||||
metrics.transfer_js = safe(ct, 'javascript', 'transferSize');
|
||||
metrics.transfer_css = safe(ct, 'css', 'transferSize');
|
||||
metrics.transfer_image = safe(ct, 'image', 'transferSize');
|
||||
metrics.transfer_font = safe(ct, 'font', 'transferSize');
|
||||
metrics.requests_js = safe(ct, 'javascript', 'requests');
|
||||
metrics.requests_css = safe(ct, 'css', 'requests');
|
||||
metrics.requests_image = safe(ct, 'image', 'requests');
|
||||
metrics.requests_font = safe(ct, 'font', 'requests');
|
||||
}
|
||||
|
||||
const tp = safe(page, 'thirdParty');
|
||||
if (tp) {
|
||||
metrics.third_party_requests = safe(tp, 'requests');
|
||||
metrics.third_party_transfer = safe(tp, 'transferSize');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── sustainable.pageSummary ───────────────────────────────────────────────
|
||||
const sustainFiles = summaries['sustainable'] || [];
|
||||
if (sustainFiles.length > 0) {
|
||||
const sust = await readJson(sustainFiles[0]);
|
||||
metrics.co2_per_page_view = safe(sust, 'co2PerPageView')
|
||||
?? safe(sust, 'statistics', 'co2PerPageView', 'median');
|
||||
metrics.co2_total = safe(sust, 'totalCO2')
|
||||
?? safe(sust, 'statistics', 'totalCO2', 'median');
|
||||
metrics.co2_first_party = safe(sust, 'firstParty', 'co2')
|
||||
?? safe(sust, 'statistics', 'firstParty', 'co2', 'median');
|
||||
metrics.co2_third_party = safe(sust, 'thirdParty', 'co2')
|
||||
?? safe(sust, 'statistics', 'thirdParty', 'co2', 'median');
|
||||
}
|
||||
|
||||
// Remove null values to avoid overwriting real DB values with NULL
|
||||
return Object.fromEntries(
|
||||
Object.entries(metrics).filter(([, v]) => v !== null && v !== undefined)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user