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:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
reports/
|
||||||
|
speedboard.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
36
Dockerfile
Normal file
36
Dockerfile
Normal 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
54
app.js
Normal 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
185
db.js
Normal 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
28
docker-compose.yml
Normal 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
15
package.json
Normal 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
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
82
queue.js
Normal file
82
queue.js
Normal 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
11
routes/history.js
Normal 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
11
routes/index.js
Normal 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
21
routes/results.js
Normal 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
14
routes/site.js
Normal 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
74
routes/status.js
Normal 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
30
routes/test.js
Normal 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
68
runner.js
Normal 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
7
views/error.pug
Normal 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
51
views/history.pug
Normal 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
45
views/index.pug
Normal 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
21
views/layout.pug
Normal 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
|
||||||
|
a(href='https://www.sitespeed.io' target='_blank' class='underline') sitespeed.io
|
||||||
15
views/partials/axe.pug
Normal file
15
views/partials/axe.pug
Normal 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
12
views/partials/co2.pug
Normal 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 & 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
17
views/partials/cwv.pug
Normal 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]
|
||||||
25
views/partials/resources.pug
Normal file
25
views/partials/resources.pug
Normal 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])
|
||||||
12
views/partials/scorecard.pug
Normal file
12
views/partials/scorecard.pug
Normal 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]
|
||||||
23
views/partials/timings.pug
Normal file
23
views/partials/timings.pug
Normal 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
34
views/results.pug
Normal 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()} •
|
||||||
|
| #{job.browser} • #{job.mobile ? 'Mobile' : 'Desktop'} • #{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
73
views/running.pug
Normal 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:
|
||||||
|
= 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:
|
||||||
|
strong= job.browser
|
||||||
|
span Runs:
|
||||||
|
strong= job.runs
|
||||||
|
span Mode:
|
||||||
|
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
78
views/site.pug
Normal 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 } }
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user