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:
2026-04-06 19:36:13 +02:00
commit 280e5f133f
28 changed files with 1222 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
reports/
speedboard.db
*.db-shm
*.db-wal

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# ─────────────────────────────────────────────────────────────────────────────
# Speedboard Docker image
#
# Based on the official sitespeed.io image which already includes:
# - Node.js 20
# - Chrome & Firefox (headless)
# - Xvfb
# - sitespeed.io CLI
# ─────────────────────────────────────────────────────────────────────────────
FROM sitespeedio/sitespeed.io:latest
WORKDIR /app
# Copy speedboard app files
COPY package.json ./
RUN npm install --omit=dev
COPY . .
# Create persistent directories
RUN mkdir -p /data/reports
# Symlink reports dir into app folder
RUN ln -sf /data/reports /app/reports
# Runtime env
ENV PORT=3132 \
IN_DOCKER=1 \
# sitespeed.io is already installed at /usr/local/lib/node_modules/sitespeed.io/bin/sitespeed.js
# but we ship our own copy — point to the bundled one inside image
SITESPEED_BIN=/usr/local/lib/node_modules/sitespeed.io/bin/sitespeed.js \
NODE_ENV=production
EXPOSE 3000
CMD ["node", "app.js"]

54
app.js Normal file
View File

@@ -0,0 +1,54 @@
import express from 'express';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { getDb } from './db.js';
// Routes
import indexRouter from './routes/index.js';
import testRouter from './routes/test.js';
import statusRouter from './routes/status.js';
import resultsRouter from './routes/results.js';
import historyRouter from './routes/history.js';
import siteRouter from './routes/site.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3132;
// Initialize DB on startup
getDb();
// View engine
app.set('views', join(__dirname, 'views'));
app.set('view engine', 'pug');
// Middleware
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// Static reports
app.use('/reports', express.static(join(__dirname, 'reports')));
// Routes
app.use('/', indexRouter);
app.use('/test', testRouter);
app.use('/status', statusRouter);
app.use('/results', resultsRouter);
app.use('/history', historyRouter);
app.use('/site', siteRouter);
// 404
app.use((req, res) => {
res.status(404).render('error', { title: '404 Not Found', message: 'Page not found.' });
});
// Error handler
app.use((err, req, res, _next) => {
console.error(err);
res.status(500).render('error', { title: 'Server Error', message: err.message });
});
app.listen(PORT, () => {
console.log(`Speedboard running at http://localhost:${PORT}`);
});

185
db.js Normal file
View File

@@ -0,0 +1,185 @@
import Database from 'better-sqlite3';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = process.env.DB_PATH || join(__dirname, 'speedboard.db');
let db;
export function getDb() {
if (!db) {
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
initSchema();
}
return db;
}
function initSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
browser TEXT NOT NULL DEFAULT 'chrome',
mobile INTEGER NOT NULL DEFAULT 0,
runs INTEGER NOT NULL DEFAULT 3,
status TEXT NOT NULL DEFAULT 'queued',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT,
finished_at TEXT,
error_msg TEXT,
-- Core Web Vitals (ms / score)
lcp REAL,
cls REAL,
tbt REAL,
fcp REAL,
ttfb REAL,
max_potential_fid REAL,
-- Visual metrics
speed_index REAL,
first_visual_change REAL,
last_visual_change REAL,
visual_complete_85 REAL,
perceptual_speed_index REAL,
-- Navigation timings (ms)
page_load_time REAL,
fully_loaded REAL,
dom_content_loaded REAL,
dom_interactive REAL,
front_end_time REAL,
back_end_time REAL,
time_to_first_byte REAL,
-- Coach scores (0-100)
score_overall REAL,
score_performance REAL,
score_accessibility REAL,
score_bestpractice REAL,
score_privacy REAL,
-- Resource sizes (bytes)
transfer_total REAL,
transfer_html REAL,
transfer_js REAL,
transfer_css REAL,
transfer_image REAL,
transfer_font REAL,
transfer_other REAL,
-- Request counts
requests_total INTEGER,
requests_js INTEGER,
requests_css INTEGER,
requests_image INTEGER,
requests_font INTEGER,
-- Third-party
third_party_requests INTEGER,
third_party_transfer REAL,
-- Accessibility (axe)
axe_critical INTEGER,
axe_serious INTEGER,
axe_moderate INTEGER,
axe_minor INTEGER,
-- CPU
long_tasks_count INTEGER,
long_tasks_duration REAL,
-- CO2
co2_per_page_view REAL,
co2_total REAL,
co2_first_party REAL,
co2_third_party REAL,
-- Raw JSON paths for drilling down
report_folder TEXT
);
CREATE INDEX IF NOT EXISTS idx_jobs_url ON jobs(url);
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
`);
}
export function createJob(id, url, browser, mobile, runs) {
const stmt = getDb().prepare(`
INSERT INTO jobs (id, url, browser, mobile, runs, status, created_at)
VALUES (?, ?, ?, ?, ?, 'queued', datetime('now'))
`);
stmt.run(id, url, browser, mobile ? 1 : 0, runs);
}
export function updateJobStatus(id, status, extras = {}) {
const db = getDb();
const fields = ['status = ?'];
const values = [status];
if (status === 'running') {
fields.push('started_at = datetime(\'now\')');
}
if (status === 'done' || status === 'error') {
fields.push('finished_at = datetime(\'now\')');
}
if (extras.error_msg !== undefined) {
fields.push('error_msg = ?');
values.push(extras.error_msg);
}
if (extras.report_folder !== undefined) {
fields.push('report_folder = ?');
values.push(extras.report_folder);
}
values.push(id);
db.prepare(`UPDATE jobs SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
export function updateJobMetrics(id, metrics) {
const db = getDb();
const keys = Object.keys(metrics);
if (keys.length === 0) return;
const sets = keys.map(k => `${k} = ?`).join(', ');
const values = keys.map(k => metrics[k]);
values.push(id);
db.prepare(`UPDATE jobs SET ${sets} WHERE id = ?`).run(...values);
}
export function getJob(id) {
return getDb().prepare('SELECT * FROM jobs WHERE id = ?').get(id);
}
export function getHistory(limit = 100) {
return getDb().prepare(`
SELECT id, url, browser, mobile, runs, status, created_at, finished_at,
lcp, fcp, tbt, speed_index, score_overall, score_performance,
transfer_total, requests_total
FROM jobs
ORDER BY created_at DESC
LIMIT ?
`).all(limit);
}
export function getSiteHistory(url, limit = 20) {
return getDb().prepare(`
SELECT * FROM jobs
WHERE url = ? AND status = 'done'
ORDER BY created_at DESC
LIMIT ?
`).all(url, limit);
}
export function getDistinctUrls() {
return getDb().prepare(`
SELECT url, COUNT(*) as count, MAX(created_at) as last_tested,
(SELECT status FROM jobs j2 WHERE j2.url = jobs.url ORDER BY created_at DESC LIMIT 1) as last_status
FROM jobs
GROUP BY url
ORDER BY MAX(created_at) DESC
`).all();
}

28
docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
version: '3.9'
services:
speedboard:
build: .
image: speedboard:latest
container_name: speedboard
restart: unless-stopped
ports:
- "3132:3132"
volumes:
# Persist reports and SQLite database outside the container
- speedboard-reports:/data/reports
- speedboard-db:/data/db
environment:
PORT: "3132"
IN_DOCKER: "1"
# Store SQLite DB on the persistent volume
DB_PATH: /data/db/speedboard.db
# sitespeed.io needs /dev/shm for Chrome
shm_size: '2gb'
# Allow Chrome --no-sandbox
security_opt:
- seccomp:unconfined
volumes:
speedboard-reports:
speedboard-db:

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "speedboard",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node app.js",
"dev": "node --watch app.js"
},
"dependencies": {
"better-sqlite3": "^12.8.0",
"express": "^5.2.1",
"pug": "^3.0.4",
"uuid": "^11.1.0"
}
}

175
parser.js Normal file
View 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)
);
}

82
queue.js Normal file
View File

@@ -0,0 +1,82 @@
import { runTest } from './runner.js';
import { parseResults } from './parser.js';
import { updateJobStatus, updateJobMetrics } from './db.js';
// SSE subscribers: jobId -> Set of send functions
const subscribers = new Map();
// Job queue
const queue = [];
let running = false;
export function subscribe(jobId, sendFn) {
if (!subscribers.has(jobId)) subscribers.set(jobId, new Set());
subscribers.get(jobId).add(sendFn);
return () => {
const set = subscribers.get(jobId);
if (set) {
set.delete(sendFn);
if (set.size === 0) subscribers.delete(jobId);
}
};
}
function emit(jobId, event, data) {
const set = subscribers.get(jobId);
if (!set) return;
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const send of set) {
try { send(payload); } catch {}
}
}
export function enqueue(job) {
queue.push(job);
emit(job.id, 'status', { message: 'Queued, waiting for runner...', phase: 'queued' });
processQueue();
}
async function processQueue() {
if (running || queue.length === 0) return;
running = true;
const job = queue.shift();
try {
updateJobStatus(job.id, 'running');
emit(job.id, 'status', { message: 'Test starting...', phase: 'running' });
const outputFolder = await runTest(job, (line) => {
emit(job.id, 'log', { line });
});
emit(job.id, 'status', { message: 'Parsing results...', phase: 'parsing' });
updateJobStatus(job.id, 'running', { report_folder: outputFolder });
let metrics = {};
try {
metrics = await parseResults(outputFolder, job.url);
updateJobMetrics(job.id, metrics);
} catch (err) {
emit(job.id, 'log', { line: `[parser warning] ${err.message}` });
}
updateJobStatus(job.id, 'done', { report_folder: outputFolder });
emit(job.id, 'status', { message: 'Done!', phase: 'done' });
emit(job.id, 'done', { jobId: job.id });
} catch (err) {
updateJobStatus(job.id, 'error', { error_msg: err.message });
emit(job.id, 'error', { message: err.message });
} finally {
running = false;
// Small delay then process next
setTimeout(processQueue, 500);
}
}
export function getQueueLength() {
return queue.length;
}
export function isRunning() {
return running;
}

11
routes/history.js Normal file
View File

@@ -0,0 +1,11 @@
import { Router } from 'express';
import { getHistory } from '../db.js';
const router = Router();
router.get('/', (req, res) => {
const jobs = getHistory(200);
res.render('history', { title: 'Test History', jobs });
});
export default router;

11
routes/index.js Normal file
View File

@@ -0,0 +1,11 @@
import { Router } from 'express';
const router = Router();
router.get('/', (req, res) => {
res.render('index', {
title: 'Speedboard — Test a URL',
prefillUrl: req.query.url || '',
});
});
export default router;

21
routes/results.js Normal file
View File

@@ -0,0 +1,21 @@
import { Router } from 'express';
import { getJob } from '../db.js';
const router = Router();
router.get('/:id', (req, res) => {
const job = getJob(req.params.id);
if (!job) return res.status(404).render('error', { title: '404', message: 'Job not found.' });
if (job.status !== 'done') return res.redirect(`/status/${job.id}`);
res.render('results', {
title: `Results — ${job.url}`,
job,
reportUrl: job.report_folder
? `/reports/${job.id}/index.html`
: null,
});
});
export default router;

14
routes/site.js Normal file
View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { getSiteHistory } from '../db.js';
const router = Router();
router.get('/', (req, res) => {
const url = req.query.url;
if (!url) return res.redirect('/history');
const jobs = getSiteHistory(url, 20);
res.render('site', { title: `Site History — ${url}`, url, jobs });
});
export default router;

74
routes/status.js Normal file
View File

@@ -0,0 +1,74 @@
import { Router } from 'express';
import { getJob } from '../db.js';
import { subscribe } from '../queue.js';
const router = Router();
// Status page (HTML)
router.get('/:id', (req, res) => {
const job = getJob(req.params.id);
if (!job) return res.status(404).render('error', { title: '404', message: 'Job not found.' });
// Already done — redirect immediately
if (job.status === 'done') return res.redirect(`/results/${job.id}`);
if (job.status === 'error') {
return res.render('running', { title: 'Test Failed', job, error: job.error_msg });
}
res.render('running', { title: `Testing ${job.url}`, job });
});
// SSE stream
router.get('/:id/stream', (req, res) => {
const job = getJob(req.params.id);
if (!job) {
res.status(404).end();
return;
}
// If already done, immediately emit done event
if (job.status === 'done') {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write(`event: done\ndata: ${JSON.stringify({ jobId: job.id })}\n\n`);
res.end();
return;
}
if (job.status === 'error') {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write(`event: error\ndata: ${JSON.stringify({ message: job.error_msg })}\n\n`);
res.end();
return;
}
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
// Keep-alive ping every 15s
const ping = setInterval(() => {
res.write(': ping\n\n');
}, 15000);
const unsubscribe = subscribe(job.id, (payload) => {
res.write(payload);
});
req.on('close', () => {
clearInterval(ping);
unsubscribe();
});
});
export default router;

30
routes/test.js Normal file
View File

@@ -0,0 +1,30 @@
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { createJob } from '../db.js';
import { enqueue } from '../queue.js';
const router = Router();
router.post('/', (req, res) => {
let { url, browser, mobile, runs } = req.body;
// Validate
if (!url || !url.startsWith('http')) {
return res.status(400).render('error', {
title: 'Invalid URL',
message: 'Please provide a valid URL starting with http:// or https://',
});
}
browser = ['chrome', 'firefox'].includes(browser) ? browser : 'chrome';
mobile = mobile === '1' || mobile === 'on' || mobile === true;
runs = Math.min(Math.max(parseInt(runs) || 3, 1), 9);
const id = uuidv4();
createJob(id, url, browser, mobile, runs);
enqueue({ id, url, browser, mobile, runs });
res.redirect(`/status/${id}`);
});
export default router;

68
runner.js Normal file
View File

@@ -0,0 +1,68 @@
import { spawn } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Path to sitespeed.io — works both locally and in Docker
const SITESPEED_BIN = process.env.SITESPEED_BIN ||
join(__dirname, '..', 'sitespeed.io', 'bin', 'sitespeed.js');
export function runTest(job, onLine) {
return new Promise((resolve, reject) => {
const outputFolder = join(__dirname, 'reports', job.id);
const args = [
SITESPEED_BIN,
job.url,
'--browser', job.browser,
'-n', String(job.runs),
'--outputFolder', outputFolder,
'--json',
'--sustainable.enable',
'--axe.enable',
'--coach',
'--headless',
];
if (job.mobile) {
args.push('--mobile');
}
// In Docker we need --xvfb false and --browsertime.chrome.args=no-sandbox
if (process.env.IN_DOCKER) {
args.push('--browsertime.chrome.args', 'no-sandbox');
args.push('--browsertime.chrome.args', 'disable-dev-shm-usage');
}
onLine(`[runner] Starting: node ${args.slice(0, 3).join(' ')} ...`);
onLine(`[runner] Output folder: ${outputFolder}`);
const child = spawn('node', args, {
cwd: __dirname,
env: { ...process.env, DISPLAY: process.env.DISPLAY || ':99' },
});
child.stdout.on('data', (data) => {
const lines = data.toString().split('\n').filter(Boolean);
for (const line of lines) onLine(line);
});
child.stderr.on('data', (data) => {
const lines = data.toString().split('\n').filter(Boolean);
for (const line of lines) onLine(`[stderr] ${line}`);
});
child.on('close', (code) => {
if (code === 0) {
resolve(outputFolder);
} else {
reject(new Error(`sitespeed.io exited with code ${code}`));
}
});
child.on('error', (err) => {
reject(new Error(`Failed to spawn sitespeed.io: ${err.message}`));
});
});
}

7
views/error.pug Normal file
View File

@@ -0,0 +1,7 @@
extends layout
block content
div(class='text-center py-20')
h1(class='text-4xl font-bold text-red-600 mb-4')= title
p(class='text-gray-600 mb-6')= message
a(href='/' class='bg-indigo-600 text-white px-5 py-2 rounded hover:bg-indigo-700') Back Home

51
views/history.pug Normal file
View File

@@ -0,0 +1,51 @@
extends layout
block content
div(class='flex items-center justify-between mb-6')
h1(class='text-2xl font-bold') Test History
a(href='/' class='bg-indigo-600 text-white text-sm px-4 py-2 rounded hover:bg-indigo-700') + New Test
if jobs.length === 0
div(class='text-center py-20 text-gray-400')
p No tests yet. Run your first test!
else
div(class='overflow-x-auto')
table(class='w-full bg-white border border-gray-200 rounded-xl overflow-hidden text-sm')
thead(class='bg-gray-50 text-gray-500 text-xs uppercase')
tr
th(class='px-4 py-3 text-left') URL
th(class='px-4 py-3 text-center') Status
th(class='px-4 py-3 text-center') Browser
th(class='px-4 py-3 text-right') LCP
th(class='px-4 py-3 text-right') FCP
th(class='px-4 py-3 text-right') TBT
th(class='px-4 py-3 text-right') Score
th(class='px-4 py-3 text-right') Tested
th(class='px-4 py-3 text-center') Actions
tbody
each job in jobs
tr(class='border-t border-gray-100 hover:bg-gray-50')
td(class='px-4 py-3 max-w-xs')
a(href=`/site?url=${encodeURIComponent(job.url)}` class='text-indigo-600 hover:underline truncate block max-w-xs' title=job.url)= job.url
td(class='px-4 py-3 text-center')
span(class=`text-xs px-2 py-0.5 rounded-full font-medium ${job.status === 'done' ? 'bg-green-100 text-green-700' : job.status === 'error' ? 'bg-red-100 text-red-700' : job.status === 'running' ? 'bg-blue-100 text-blue-700' : 'bg-yellow-100 text-yellow-700'}`)
= job.status
td(class='px-4 py-3 text-center text-gray-600')= job.browser + (job.mobile ? ' 📱' : '')
td(class='px-4 py-3 text-right')= job.lcp ? (job.lcp / 1000).toFixed(2) + 's' : '—'
td(class='px-4 py-3 text-right')= job.fcp ? (job.fcp / 1000).toFixed(2) + 's' : '—'
td(class='px-4 py-3 text-right')= job.tbt ? job.tbt.toFixed(0) + 'ms' : '—'
td(class='px-4 py-3 text-right')
if job.score_performance
span(class=`font-bold ${job.score_performance >= 90 ? 'text-green-600' : job.score_performance >= 50 ? 'text-yellow-600' : 'text-red-600'}`)
= job.score_performance
else
= '—'
td(class='px-4 py-3 text-right text-gray-400 text-xs')
= new Date(job.created_at).toLocaleDateString()
td(class='px-4 py-3 text-center')
if job.status === 'done'
a(href=`/results/${job.id}` class='text-indigo-600 hover:underline text-xs mr-2') Results
else if job.status === 'running' || job.status === 'queued'
a(href=`/status/${job.id}` class='text-blue-600 hover:underline text-xs mr-2') Live
a(href=`/?url=${encodeURIComponent(job.url)}` class='text-gray-500 hover:underline text-xs') Retest

45
views/index.pug Normal file
View File

@@ -0,0 +1,45 @@
extends layout
block content
div(class='max-w-2xl mx-auto')
h1(class='text-3xl font-bold mb-2 text-indigo-700') Website Speed Test
p(class='text-gray-500 mb-8') Test any URL with sitespeed.io. Get Core Web Vitals, coach scores, resources, accessibility & CO₂ metrics.
form(action='/test' method='POST' class='bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-5')
div
label(for='url' class='block text-sm font-semibold mb-1') URL
input#url(
type='url'
name='url'
required
placeholder='https://example.com'
value=prefillUrl
class='w-full border border-gray-300 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500'
)
div(class='grid grid-cols-3 gap-4')
div
label(for='browser' class='block text-sm font-semibold mb-1') Browser
select#browser(name='browser' class='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500')
option(value='chrome') Chrome
option(value='firefox') Firefox
div
label(for='runs' class='block text-sm font-semibold mb-1') Runs
select#runs(name='runs' class='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500')
option(value='1') 1 run
option(value='3' selected) 3 runs
option(value='5') 5 runs
option(value='9') 9 runs
div(class='flex items-end pb-0.5')
label(class='flex items-center gap-2 cursor-pointer')
input#mobile(type='checkbox' name='mobile' value='1' class='w-4 h-4 text-indigo-600')
span(class='text-sm font-semibold') Mobile mode
button(type='submit' class='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2.5 rounded-lg transition')
| ⚡ Run Test
if prefillUrl
script.
document.getElementById('url').value = decodeURIComponent('!{encodeURIComponent(prefillUrl)}');

21
views/layout.pug Normal file
View File

@@ -0,0 +1,21 @@
doctype html
html(lang='en')
head
meta(charset='UTF-8')
meta(name='viewport' content='width=device-width, initial-scale=1.0')
title= title + ' | Speedboard'
script(src='https://cdn.tailwindcss.com')
style.
.log-line { font-family: monospace; font-size: 0.78rem; }
.score-circle { border-radius: 50%; display:inline-flex; align-items:center; justify-content:center; font-weight:700; }
.metric-card { background:#fff; border:1px solid #e5e7eb; border-radius:0.5rem; padding:1rem; }
body(class='bg-gray-50 text-gray-800 min-h-screen')
nav(class='bg-indigo-700 text-white px-6 py-3 flex items-center gap-6 shadow')
a(href='/' class='font-bold text-lg tracking-tight') ⚡ Speedboard
a(href='/history' class='text-indigo-200 hover:text-white text-sm') History
a(href='/' class='text-indigo-200 hover:text-white text-sm') New Test
main(class='max-w-6xl mx-auto px-4 py-8')
block content
footer(class='text-center text-xs text-gray-400 py-6')
| Powered by&nbsp;
a(href='https://www.sitespeed.io' target='_blank' class='underline') sitespeed.io

15
views/partials/axe.pug Normal file
View File

@@ -0,0 +1,15 @@
- function num(v) { return v != null ? v : '—'; }
- const hasAxe = job.axe_critical != null || job.axe_serious != null
div(class='bg-white border border-gray-200 rounded-xl p-5')
h2(class='text-base font-semibold mb-4') Accessibility (axe)
if !hasAxe
p(class='text-sm text-gray-400') No axe data collected.
else
div(class='grid grid-cols-2 sm:grid-cols-4 gap-3')
each item in [['Critical', job.axe_critical, 'bg-red-50 border-red-300 text-red-700'], ['Serious', job.axe_serious, 'bg-orange-50 border-orange-300 text-orange-700'], ['Moderate', job.axe_moderate, 'bg-yellow-50 border-yellow-300 text-yellow-700'], ['Minor', job.axe_minor, 'bg-blue-50 border-blue-300 text-blue-700']]
div(class=`metric-card border ${item[2]} text-center`)
div(class='text-2xl font-bold')= num(item[1])
div(class='text-xs mt-1')= item[0]
if job.axe_critical === 0 && job.axe_serious === 0
p(class='text-sm text-green-600 mt-3 font-medium') No critical or serious violations found.

12
views/partials/co2.pug Normal file
View File

@@ -0,0 +1,12 @@
- function g(v) { return v != null ? v.toFixed(4)+' g CO₂' : '—'; }
div(class='bg-white border border-gray-200 rounded-xl p-5')
h2(class='text-base font-semibold mb-4') Sustainability &amp; CO₂
if job.co2_per_page_view == null
p(class='text-sm text-gray-400') No CO₂ data collected.
else
div(class='grid grid-cols-2 sm:grid-cols-4 gap-3')
each item in [['Per page view', job.co2_per_page_view], ['Total', job.co2_total], ['First-party', job.co2_first_party], ['Third-party', job.co2_third_party]]
div(class='metric-card text-center')
div(class='text-lg font-bold text-green-700')= g(item[1])
div(class='text-xs text-gray-500 mt-1')= item[0]

17
views/partials/cwv.pug Normal file
View File

@@ -0,0 +1,17 @@
- function ms(v) { return v != null ? (v/1000).toFixed(2)+'s' : '—'; }
- function raw(v, unit) { return v != null ? v.toFixed(unit === 'ms' ? 0 : 3)+unit : '—'; }
- function cwvClass(metric, val) {
- if (val == null) return 'bg-gray-50 border-gray-200';
- const thresholds = { lcp: [2500, 4000], fcp: [1800, 3000], tbt: [200, 600], cls: [0.1, 0.25], ttfb: [800, 1800] };
- const t = thresholds[metric];
- if (!t) return 'bg-gray-50 border-gray-200';
- return val <= t[0] ? 'bg-green-50 border-green-300' : val <= t[1] ? 'bg-yellow-50 border-yellow-300' : 'bg-red-50 border-red-300';
- }
div(class='bg-white border border-gray-200 rounded-xl p-5')
h2(class='text-base font-semibold mb-4') Core Web Vitals
div(class='grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3')
each item in [['LCP', 'lcp', ms(job.lcp)], ['FCP', 'fcp', ms(job.fcp)], ['TBT', 'tbt', raw(job.tbt,'ms')], ['CLS', 'cls', raw(job.cls,'')], ['TTFB', 'ttfb', ms(job.ttfb)]]
div(class=`metric-card border ${cwvClass(item[1], job[item[1]])} text-center`)
div(class='text-2xl font-bold')= item[2]
div(class='text-xs text-gray-500 mt-1')= item[0]

View File

@@ -0,0 +1,25 @@
- function kb(v) { return v != null ? (v/1024).toFixed(1)+' KB' : '—'; }
- function num(v) { return v != null ? v : '—'; }
div(class='bg-white border border-gray-200 rounded-xl p-5')
h2(class='text-base font-semibold mb-4') Resources
div(class='grid grid-cols-1 md:grid-cols-2 gap-6')
div
h3(class='text-xs font-semibold text-gray-400 uppercase mb-2') Transfer Size
table(class='w-full text-sm')
tbody
each row in [['Total', job.transfer_total], ['HTML', job.transfer_html], ['JavaScript', job.transfer_js], ['CSS', job.transfer_css], ['Images', job.transfer_image], ['Fonts', job.transfer_font]]
tr(class='border-b border-gray-100')
td(class='py-1.5 text-gray-500')= row[0]
td(class='py-1.5 text-right font-medium')= kb(row[1])
tr(class='border-t border-gray-200')
td(class='py-1.5 text-gray-500') Third-party
td(class='py-1.5 text-right font-medium')= kb(job.third_party_transfer)
div
h3(class='text-xs font-semibold text-gray-400 uppercase mb-2') Request Counts
table(class='w-full text-sm')
tbody
each row in [['Total requests', job.requests_total], ['JavaScript', job.requests_js], ['CSS', job.requests_css], ['Images', job.requests_image], ['Fonts', job.requests_font], ['Third-party', job.third_party_requests]]
tr(class='border-b border-gray-100')
td(class='py-1.5 text-gray-500')= row[0]
td(class='py-1.5 text-right font-medium')= num(row[1])

View File

@@ -0,0 +1,12 @@
- function scoreColor(s) { return s == null ? '#9ca3af' : s >= 90 ? '#22c55e' : s >= 50 ? '#f59e0b' : '#ef4444'; }
- function scoreBg(s) { return s == null ? 'bg-gray-100 text-gray-400' : s >= 90 ? 'bg-green-100 text-green-700' : s >= 50 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'; }
- const scores = [['Overall', job.score_overall], ['Performance', job.score_performance], ['Accessibility', job.score_accessibility], ['Best Practice', job.score_bestpractice], ['Privacy', job.score_privacy]]
div(class='bg-white border border-gray-200 rounded-xl p-5')
h2(class='text-base font-semibold mb-4') Coach Scores
div(class='flex flex-wrap gap-4 justify-start')
each item in scores
div(class='flex flex-col items-center gap-1')
div(class=`score-circle w-16 h-16 text-xl ${scoreBg(item[1])}`)
= item[1] != null ? Math.round(item[1]) : '?'
span(class='text-xs text-gray-500 text-center')= item[0]

View File

@@ -0,0 +1,23 @@
- function ms(v) { return v != null ? v.toFixed(0)+' ms' : '—'; }
- const rows = [
- ['Page Load Time', job.page_load_time],
- ['Fully Loaded', job.fully_loaded],
- ['DOM Content Loaded', job.dom_content_loaded],
- ['DOM Interactive', job.dom_interactive],
- ['Speed Index', job.speed_index],
- ['First Visual Change', job.first_visual_change],
- ['Last Visual Change', job.last_visual_change],
- ['Visual Complete 85%', job.visual_complete_85],
- ['Perceptual Speed Index', job.perceptual_speed_index],
- ['Front End Time', job.front_end_time],
- ['Back End Time', job.back_end_time],
- ['Max Potential FID', job.max_potential_fid],
- ]
div(class='bg-white border border-gray-200 rounded-xl p-5')
h2(class='text-base font-semibold mb-4') Navigation & Visual Timings
div(class='grid grid-cols-2 sm:grid-cols-3 gap-2')
each row in rows
div(class='bg-gray-50 rounded-lg px-3 py-2 flex justify-between items-center')
span(class='text-xs text-gray-500')= row[0]
span(class='text-sm font-semibold')= ms(row[1])

34
views/results.pug Normal file
View File

@@ -0,0 +1,34 @@
extends layout
block content
div(class='space-y-6')
//- Header
div(class='flex flex-wrap items-start justify-between gap-4')
div
h1(class='text-2xl font-bold break-all')= job.url
p(class='text-xs text-gray-400 mt-1')
| Tested #{new Date(job.finished_at).toLocaleString()} &bull;
| #{job.browser} &bull; #{job.mobile ? 'Mobile' : 'Desktop'} &bull; #{job.runs} run(s)
div(class='flex gap-2 flex-wrap')
a(href=`/?url=${encodeURIComponent(job.url)}` class='text-sm bg-indigo-600 text-white px-4 py-1.5 rounded hover:bg-indigo-700') Retest
a(href=`/site?url=${encodeURIComponent(job.url)}` class='text-sm bg-gray-200 text-gray-700 px-4 py-1.5 rounded hover:bg-gray-300') Site History
if reportUrl
a(href=reportUrl target='_blank' class='text-sm bg-green-600 text-white px-4 py-1.5 rounded hover:bg-green-700') Full Report ↗
//- Coach Scores
include partials/scorecard
//- Core Web Vitals
include partials/cwv
//- Navigation Timings
include partials/timings
//- Resources
include partials/resources
//- Accessibility
include partials/axe
//- CO2
include partials/co2

73
views/running.pug Normal file
View File

@@ -0,0 +1,73 @@
extends layout
block content
div(class='max-w-3xl mx-auto')
div(class='flex items-center justify-between mb-4')
div
h1(class='text-2xl font-bold') Testing URL
p(class='text-sm text-gray-500 truncate max-w-xl')= job.url
span#status-badge(class='text-xs px-3 py-1 rounded-full bg-yellow-100 text-yellow-800 font-semibold') Queued
if error
div(class='bg-red-50 border border-red-300 text-red-700 rounded-lg p-4 mb-4')
strong Test failed:&nbsp;
= error
div(class='bg-gray-900 text-green-400 rounded-xl p-4 h-96 overflow-y-auto text-xs font-mono' id='log-box')
p(class='text-gray-500') Waiting for output...
div(class='mt-4 text-sm text-gray-500 flex gap-4')
span Browser:&nbsp;
strong= job.browser
span Runs:&nbsp;
strong= job.runs
span Mode:&nbsp;
strong= job.mobile ? 'Mobile' : 'Desktop'
script.
const jobId = '!{job.id}';
const logBox = document.getElementById('log-box');
const badge = document.getElementById('status-badge');
let firstLine = true;
const es = new EventSource(`/status/${jobId}/stream`);
es.addEventListener('log', (e) => {
const d = JSON.parse(e.data);
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
const el = document.createElement('div');
el.className = 'log-line';
el.textContent = d.line;
logBox.appendChild(el);
logBox.scrollTop = logBox.scrollHeight;
});
es.addEventListener('status', (e) => {
const d = JSON.parse(e.data);
badge.textContent = d.message;
badge.className = 'text-xs px-3 py-1 rounded-full font-semibold ' +
(d.phase === 'done' ? 'bg-green-100 text-green-800' :
d.phase === 'error' ? 'bg-red-100 text-red-800' :
d.phase === 'parsing' ? 'bg-blue-100 text-blue-800' :
'bg-yellow-100 text-yellow-800');
});
es.addEventListener('done', (e) => {
es.close();
badge.textContent = 'Done! Redirecting...';
badge.className = 'text-xs px-3 py-1 rounded-full bg-green-100 text-green-800 font-semibold';
setTimeout(() => { window.location.href = `/results/${jobId}`; }, 800);
});
es.addEventListener('error', (e) => {
es.close();
badge.textContent = 'Error';
badge.className = 'text-xs px-3 py-1 rounded-full bg-red-100 text-red-800 font-semibold';
try {
const d = JSON.parse(e.data);
const el = document.createElement('div');
el.className = 'text-red-400 log-line';
el.textContent = '[ERROR] ' + d.message;
logBox.appendChild(el);
} catch {}
});

78
views/site.pug Normal file
View File

@@ -0,0 +1,78 @@
extends layout
block content
div(class='space-y-6')
div(class='flex items-start justify-between gap-4 flex-wrap')
div
h1(class='text-xl font-bold break-all')= url
p(class='text-sm text-gray-400 mt-1') #{jobs.length} completed test(s) on record
a(href=`/?url=${encodeURIComponent(url)}` class='bg-indigo-600 text-white text-sm px-4 py-2 rounded hover:bg-indigo-700') + Retest
if jobs.length === 0
div(class='text-center py-16 text-gray-400') No completed tests found for this URL.
else
//- LCP trend chart
div(class='bg-white border border-gray-200 rounded-xl p-5')
h2(class='text-base font-semibold mb-3') LCP Trend (ms)
canvas#lcpChart(height='80')
//- Timeline table
div(class='overflow-x-auto')
table(class='w-full bg-white border border-gray-200 rounded-xl overflow-hidden text-sm')
thead(class='bg-gray-50 text-gray-500 text-xs uppercase')
tr
th(class='px-4 py-3 text-left') Date
th(class='px-4 py-3 text-center') Browser
th(class='px-4 py-3 text-right') LCP
th(class='px-4 py-3 text-right') FCP
th(class='px-4 py-3 text-right') TBT
th(class='px-4 py-3 text-right') Speed Index
th(class='px-4 py-3 text-right') Perf Score
th(class='px-4 py-3 text-right') Transfer
th
tbody
each job in jobs
tr(class='border-t border-gray-100 hover:bg-gray-50')
td(class='px-4 py-3 text-gray-500 text-xs')= new Date(job.finished_at).toLocaleString()
td(class='px-4 py-3 text-center text-gray-600 text-xs')= job.browser + (job.mobile ? ' 📱' : '')
td(class='px-4 py-3 text-right')= job.lcp ? (job.lcp/1000).toFixed(2)+'s' : '—'
td(class='px-4 py-3 text-right')= job.fcp ? (job.fcp/1000).toFixed(2)+'s' : '—'
td(class='px-4 py-3 text-right')= job.tbt ? job.tbt.toFixed(0)+'ms' : '—'
td(class='px-4 py-3 text-right')= job.speed_index ? job.speed_index.toFixed(0) : '—'
td(class='px-4 py-3 text-right')
if job.score_performance
span(class=`font-bold ${job.score_performance >= 90 ? 'text-green-600' : job.score_performance >= 50 ? 'text-yellow-600' : 'text-red-600'}`)
= job.score_performance
else
= '—'
td(class='px-4 py-3 text-right text-xs')
= job.transfer_total ? (job.transfer_total/1024).toFixed(0)+'KB' : '—'
td(class='px-4 py-3')
a(href=`/results/${job.id}` class='text-indigo-600 hover:underline text-xs') View
script(src='https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js')
script.
const jobs = !{JSON.stringify(jobs.map(j => ({ date: j.finished_at, lcp: j.lcp })))};
const labels = jobs.map(j => new Date(j.date).toLocaleDateString()).reverse();
const data = jobs.map(j => j.lcp || null).reverse();
new Chart(document.getElementById('lcpChart'), {
type: 'line',
data: {
labels,
datasets: [{
label: 'LCP (ms)',
data,
borderColor: '#6366f1',
backgroundColor: 'rgba(99,102,241,0.1)',
tension: 0.3,
fill: true,
pointRadius: 4,
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } }
}
});