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

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;
}