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