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

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;